diff --git a/app/build.gradle b/app/build.gradle index ba17830c1e..c56a2c8e3d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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() { diff --git a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt index a20a3a2a67..6fb3888ff2 100644 --- a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt @@ -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 diff --git a/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt index 157085135e..54470569e1 100644 --- a/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt @@ -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, diff --git a/app/src/androidTest/java/network/loki/messenger/SodiumUtilitiesTest.kt b/app/src/androidTest/java/network/loki/messenger/SodiumUtilitiesTest.kt index af260a0bf0..028a76a33f 100644 --- a/app/src/androidTest/java/network/loki/messenger/SodiumUtilitiesTest.kt +++ b/app/src/androidTest/java/network/loki/messenger/SodiumUtilitiesTest.kt @@ -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) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 79d55b37f8..7e04ad44ca 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -100,31 +100,32 @@ android:value="false" /> - + 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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt index 6133f2dee7..ea4108ba9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt @@ -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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt index 846a94483f..6d59bbfc92 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt index b46243eeb0..f9fd528707 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt index 16e74cdde9..f66512d79f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt @@ -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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt index 3fec60a0a3..51ac1d445e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt @@ -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 @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt index c2524bf261..20f1821e4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt @@ -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 { @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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/paging/ConversationPager.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/paging/ConversationPager.kt deleted file mode 100644 index 827c394546..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/paging/ConversationPager.kt +++ /dev/null @@ -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() { - 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() { - - override fun getRefreshKey(state: PagingState): 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() - - @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): LoadResult { - 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() - 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) } - ) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt deleted file mode 100644 index df2bc1c371..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt +++ /dev/null @@ -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() { - var items = listOf() - 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) - } - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationDelegate.kt index 7e51e1de07..04b16b64ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationDelegate.kt @@ -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() {} } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt index 7b666be56f..1ffc65f592 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt @@ -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(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(com.google.android.material.R.id.design_bottom_sheet)?.apply { + modifyLayoutParams { 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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt deleted file mode 100644 index 92f050f76a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeViewModel.kt deleted file mode 100644 index 47fc50598c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeViewModel.kt +++ /dev/null @@ -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>() - val recipients: LiveData> = _recipients - - init { - viewModelScope.launch { - threadDb.approvedConversationList.use { openCursor -> - val reader = threadDb.readerFor(openCursor) - val threads = mutableListOf() - while (true) { - threads += reader.next?.recipient ?: break - } - withContext(Dispatchers.Main) { - _recipients.value = threads - } - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt new file mode 100644 index 0000000000..452968429e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversationFragment.kt new file mode 100644 index 0000000000..1934c5e5bb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversationFragment.kt @@ -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(NullStartConversationDelegate) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = createThemedComposeView { + StartConversationScreen( + accountId = TextSecurePreferences.getLocalNumber(requireContext())!!, + delegate = delegate.collectAsState().value + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt new file mode 100644 index 0000000000..256756521e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt @@ -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") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriendFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriendFragment.kt new file mode 100644 index 0000000000..4239a7a067 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriendFragment.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/Callbacks.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/Callbacks.kt new file mode 100644 index 0000000000..02d39b1327 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/Callbacks.kt @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.conversation.start.newmessage + +internal interface Callbacks { + fun onChange(value: String) {} + fun onContinue() {} + fun onScanQrCode(value: String) {} +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt new file mode 100644 index 0000000000..82f9d7ae1f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt @@ -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 = 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")) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageFragment.kt new file mode 100644 index 0000000000..8c383fe1a9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageFragment.kt @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt new file mode 100644 index 0000000000..a08282da74 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt @@ -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() + val success get() = _success.asSharedFlow() + + private val _qrErrors = MutableSharedFlow(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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 5e2e886085..4aeeb66fe8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -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)?.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() } // Create the message val message = VisibleMessage().applyExpiryMode(viewModel.threadId) @@ -2045,9 +2039,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe endActionMode() } - override fun copySessionID(messages: Set) { - val sessionID = messages.first().individualRecipient.address.toString() - val clip = ClipData.newPlainText("Session ID", sessionID) + override fun copyAccountID(messages: Set) { + 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) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt index 340336e53b..40a089d4f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index af754a300a..56c6540ed5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index b29f9e1b49..cbae0e757f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt index b6212b8542..ca5b1cec11 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt index 925968f95b..d43225b5d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt @@ -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, 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, 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) { 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) { 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) { 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) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt index c0ff1cbb1d..46feefb608 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt index 5edd63f100..1af1d669cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index b3de59f9ee..8e38c7d38e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -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) -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt index fbb4d2231f..d4068a3e6c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt @@ -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, ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt index 21398c71aa..c7862ca22e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt @@ -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) fun banAndDeleteAll(messages: Set) fun copyMessages(messages: Set) - fun copySessionID(messages: Set) + fun copyAccountID(messages: Set) fun resyncMessage(messages: Set) fun resendMessage(messages: Set) fun showMessageDetail(messages: Set) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index 11069937a0..177becd497 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt index c4a29fea54..77565244a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 9470d17de5..2019867f80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt index 1c8aa6774a..4d3e48bc5b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt @@ -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 } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/BlindedIdMappingDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/BlindedIdMappingDatabase.kt index 950c1c6bcf..a5919d4394 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/BlindedIdMappingDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/BlindedIdMappingDatabase.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt index 778af6c01c..27b3e73397 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt @@ -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): List { + fun getContacts(accountIDs: Collection): List { 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)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 29c637bc23..3115275773 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -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 = 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 { @@ -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, 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) } } -} \ No newline at end of file +} + +/** + * 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Util.kt new file mode 100644 index 0000000000..402ee6155f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Util.kt @@ -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() \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/EnterPublicKeyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/EnterPublicKeyFragment.kt deleted file mode 100644 index c3afbf5b77..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/dms/EnterPublicKeyFragment.kt +++ /dev/null @@ -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) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt deleted file mode 100644 index 74e2cac4c8..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt +++ /dev/null @@ -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 - } - }) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragmentAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragmentAdapter.kt deleted file mode 100644 index 3a07bcb518..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragmentAdapter.kt +++ /dev/null @@ -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() - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt index 75c7681b1d..7bfea9aab0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt @@ -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?, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt index ae59c3833e..964e1e1770 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt @@ -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?, diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index 36ea3a4371..4647156a01 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/EmptyView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/EmptyView.kt new file mode 100644 index 0000000000..9d078df4f7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/EmptyView.kt @@ -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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 4b0cf60c3c..ee82a708c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -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() private val homeViewModel by viewModels() - 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 { + 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 { + putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey)) } + is GlobalSearchAdapter.Model.Contact -> push { + 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 { 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() } } } // 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 = 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 get() { + class NamedValue(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 get() = + contacts.map { GlobalSearchAdapter.Model.Contact(it, it.nickname ?: it.name, it.accountID == publicKey) } + + threads.map(GlobalSearchAdapter.Model::GroupConversation) + + private val GlobalSearchResult.messageResults: List 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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt index 571adb7358..5410df9d47 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt @@ -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) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt b/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt new file mode 100644 index 0000000000..4dccc4224f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt @@ -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 {} + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt index fffed1baba..cae399dcbf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt index 7cf953be24..71c2c62506 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt @@ -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() { +class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerView.Adapter() { 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 = listOf() private var query: String? = null + fun setNewData(data: Pair>) = setNewData(data.first, data.second) + fun setNewData(query: String, newData: List) { 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() } - -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt index 5371bb71c9..d390776d1c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt @@ -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)" \ No newline at end of file + 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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchInputLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchInputLayout.kt index c22ccde1f1..442e6159fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchInputLayout.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchInputLayout.kt @@ -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(null) - val query: StateFlow = _query + private val _query = MutableStateFlow("") + val query: StateFlow = _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(LengthFilter(100)) ) // 100 char search limit - binding.searchCancel.setOnClickListener(this) - binding.searchClear.setOnClickListener(this) - } - - override fun onDetachedFromWindow() { - super.onDetachedFromWindow() + binding.searchInput.filters = arrayOf(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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt index c85ffa8745..29e11067a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt @@ -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, - val threads: List, - val messages: List + val query: String, + val contacts: List = emptyList(), + val threads: List = emptyList(), + val messages: List = 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() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt index 1ff0a395fe..fb4a61a558 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt @@ -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() + private val _queryText = MutableStateFlow("") - 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() + 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 = MutableStateFlow(GlobalSearchResult.EMPTY) - - val result: StateFlow = _result - - private val _queryText: MutableStateFlow = 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() - 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) + } } -} \ No newline at end of file +} + +/** + * Re-emit whenever refreshes emits. + * */ +@OptIn(ExperimentalCoroutinesApi::class) +private fun Flow.reEmit(refreshes: Flow) = flatMapLatest { query -> merge(flowOf(query), refreshes.map { query }) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt index e583fb0ca5..63f6d07da1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java index 9b0ff9d85c..e80c47a9f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java index cad3b6f6c5..7042a1766a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java @@ -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; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java index 2aaa593b58..d5aeba6022 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java @@ -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; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/DisplayNameActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/DisplayNameActivity.kt deleted file mode 100644 index c0699e3eb5..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/DisplayNameActivity.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/FakeChatView.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/FakeChatView.kt deleted file mode 100644 index dcd4d783e7..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/FakeChatView.kt +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt deleted file mode 100644 index 1c10571dbd..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt +++ /dev/null @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/OnboardingBackPressAlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/OnboardingBackPressAlertDialog.kt new file mode 100644 index 0000000000..c620f32b6a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/OnboardingBackPressAlertDialog.kt @@ -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)) + ) + ) + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt deleted file mode 100644 index e4e8e6a9a6..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt deleted file mode 100644 index 13e5b51f0e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/SeedActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/SeedActivity.kt deleted file mode 100644 index 0eab58fa0c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/SeedActivity.kt +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/SeedReminderView.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/SeedReminderView.kt deleted file mode 100644 index 28611985fa..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/SeedReminderView.kt +++ /dev/null @@ -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() -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt new file mode 100644 index 0000000000..55d0cb14e2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt @@ -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) +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/LandingActivity.kt similarity index 50% rename from app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt rename to app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/LandingActivity.kt index c878a79eef..3be3eafcc2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/LandingActivity.kt @@ -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() }, + 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) - } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt new file mode 100644 index 0000000000..5fe7b64fce --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt @@ -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, + 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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt new file mode 100644 index 0000000000..3c7a6f6a56 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt @@ -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() + } + } + + setComposeContent { + val state by viewModel.stateFlow.collectAsState() + LoadAccountScreen(state, viewModel.qrErrors, viewModel::onChange, viewModel::onContinue, viewModel::onScanQrCode) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountViewModel.kt new file mode 100644 index 0000000000..f98c725dea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountViewModel.kt @@ -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() + val events = _events.asSharedFlow() + + private val _qrErrors = MutableSharedFlow(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) } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/Loading.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/Loading.kt new file mode 100644 index 0000000000..30d9a5acdb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/Loading.kt @@ -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)) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingActivity.kt new file mode 100644 index 0000000000..9c8a1869d4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingActivity.kt @@ -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) + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingViewModel.kt new file mode 100644 index 0000000000..a7871d5620 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingViewModel.kt @@ -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() + 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 = 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) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt new file mode 100644 index 0000000000..1e0a21d571 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt new file mode 100644 index 0000000000..5a40103830 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt @@ -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() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt new file mode 100644 index 0000000000..c8cc30ce79 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsActivity.kt new file mode 100644 index 0000000000..42cf49bce8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsActivity.kt @@ -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() + 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 { putExtra(EXTRA_PROFILE_NAME, profileName) } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsViewModel.kt new file mode 100644 index 0000000000..a39f270bf2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsViewModel.kt @@ -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() + 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 create(modelClass: Class): 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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt new file mode 100644 index 0000000000..f5e6e1a9a4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt @@ -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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameActivity.kt new file mode 100644 index 0000000000..97aacf82e6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameActivity.kt @@ -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) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameViewModel.kt new file mode 100644 index 0000000000..99b697037d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameViewModel.kt @@ -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() + 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 create(modelClass: Class): 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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/ui/ContinueButton.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/ui/ContinueButton.kt new file mode 100644 index 0000000000..d48b5f56ab --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/ui/ContinueButton.kt @@ -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, + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt index d03ae98030..17d97dec7b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt @@ -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() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationSettingsActivity.kt index b18859ea07..2a34de808b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationSettingsActivity.kt @@ -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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt index 20f67cd29e..ccf7f9c178 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt @@ -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(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 { + 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, 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 \ No newline at end of file + +@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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt index f80acee64e..60b5fb8b2c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt @@ -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( } } } - } data class RadioOption( diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SeedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SeedDialog.kt deleted file mode 100644 index bae5f19605..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SeedDialog.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index 3d782b1c12..ca69d37bc1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -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() 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(R.id.profile_picture_view) - ?.also(::setupProfilePictureView) + val profilePic = findViewById(R.id.profile_picture_view) + ?.also(::setupProfilePictureView) - val pictureIcon = findViewById(R.id.ic_pictures) + val pictureIcon = findViewById(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 } } -} \ No newline at end of file + + @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() } + } + Divider() + LargeItemButton(R.string.activity_settings_privacy_button_title, R.drawable.ic_privacy_icon) { show() } + Divider() + LargeItemButton(R.string.activity_settings_notifications_button_title, R.drawable.ic_speaker, Modifier.contentDescription(R.string.AccessibilityId_notifications)) { show() } + Divider() + LargeItemButton(R.string.activity_settings_conversations_button_title, R.drawable.ic_conversations, Modifier.contentDescription(R.string.AccessibilityId_conversations)) { show() } + Divider() + LargeItemButton(R.string.activity_settings_message_requests_button_title, R.drawable.ic_message_requests, Modifier.contentDescription(R.string.AccessibilityId_message_requests)) { show() } + Divider() + LargeItemButton(R.string.activity_settings_message_appearance_button_title, R.drawable.ic_appearance, Modifier.contentDescription(R.string.AccessibilityId_appearance)) { show() } + 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() } + Divider() + } + LargeItemButton(R.string.activity_settings_help_button, R.drawable.ic_help, Modifier.contentDescription(R.string.AccessibilityId_help)) { show() } + 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 = LocalBroadcastManager.getInstance(this).hasPaths() +private fun LocalBroadcastManager.hasPaths(): Flow = 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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/Util.kt new file mode 100644 index 0000000000..1271ece02e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/Util.kt @@ -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() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt index 823728c359..34547c999e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java index 1c05e68bdf..34a427547b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java @@ -9,7 +9,7 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; -import org.session.libsession.messaging.utilities.SessionId; +import org.session.libsession.messaging.utilities.AccountId; import org.thoughtcrime.securesms.components.ProfilePictureView; import org.thoughtcrime.securesms.components.emoji.EmojiImageView; import org.thoughtcrime.securesms.database.model.MessageId; @@ -161,7 +161,7 @@ final class ReactionRecipientsAdapter extends RecyclerView.Adapter Unit = {}, + onHide:() -> Unit = {} +) { + Column( + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xsMargin), + modifier = Modifier + .contentDescription(R.string.AccessibilityId_recovery_password) + .verticalScroll(rememberScrollState()) + .padding(bottom = LocalDimensions.current.xsMargin) + ) { + RecoveryPasswordCell(mnemonic, seed, copyMnemonic) + HideRecoveryPasswordCell(onHide) + } +} + +@Composable +private fun RecoveryPasswordCell( + mnemonic: String, + seed: String?, + copyMnemonic:() -> Unit = {} +) { + var showQr by remember { + mutableStateOf(false) + } + + CellWithPaddingAndMargin { + Column { + Row { + Text( + stringResource(R.string.sessionRecoveryPassword), + style = h8 + ) + Spacer(Modifier.width(LocalDimensions.current.xxsItemSpacing)) + SessionShieldIcon() + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsMargin)) + + Text( + stringResource(R.string.recoveryPasswordDescription), + style = base + ) + + AnimatedVisibility(!showQr) { + RecoveryPassword(mnemonic) + } + + AnimatedVisibility( + showQr, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) { + QrImage( + seed, + modifier = Modifier + .padding(vertical = LocalDimensions.current.smallMargin) + .contentDescription(R.string.AccessibilityId_qr_code), + contentPadding = 10.dp, + icon = R.drawable.session_shield + ) + } + + AnimatedVisibility(!showQr) { + Row( + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallItemSpacing), + verticalAlignment = Alignment.CenterVertically + ) { + SlimOutlineCopyButton( + Modifier.weight(1f), + onClick = copyMnemonic + ) + SlimOutlineButton( + stringResource(R.string.qrView), + Modifier.weight(1f), + ) { showQr = !showQr } + } + } + + AnimatedVisibility(showQr, modifier = Modifier.align(Alignment.CenterHorizontally)) { + SlimOutlineButton( + stringResource(R.string.recoveryPasswordView), + onClick = { showQr = !showQr } + ) + } + } + } +} + +@Composable +private fun RecoveryPassword(mnemonic: String) { + Text( + mnemonic, + modifier = Modifier + .contentDescription(R.string.AccessibilityId_recovery_password_container) + .padding(vertical = LocalDimensions.current.smallMargin) + .border() + .padding(LocalDimensions.current.smallMargin), + textAlign = TextAlign.Center, + style = extraSmallMonospace, + color = LocalColors.current.run { if (isLight) text else primary }, + ) +} + +@Composable +private fun HideRecoveryPasswordCell(onHide: () -> Unit = {}) { + CellWithPaddingAndMargin { + Row { + Column( + Modifier.weight(1f) + ) { + Text( + stringResource(R.string.recoveryPasswordHideRecoveryPassword), + style = h8 + ) + Text( + stringResource(R.string.recoveryPasswordHideRecoveryPasswordDescription), + style = base + ) + } + Spacer(modifier = Modifier.width(LocalDimensions.current.xxsMargin)) + SlimOutlineButton( + text = stringResource(R.string.hide), + modifier = Modifier + .wrapContentWidth() + .align(Alignment.CenterVertically) + .contentDescription(R.string.AccessibilityId_hide_recovery_password_button), + color = LocalColors.current.danger, + onClick = onHide + ) + } + } +} + +@Preview +@Composable +private fun PreviewRecoveryPasswordScreen( + @PreviewParameter(SessionColorsParameterProvider::class) colors: Colors +) { + PreviewTheme(colors) { + RecoveryPasswordScreen(mnemonic = "voyage urban toyed maverick peculiar tuxedo penguin tree grass building listen speak withdraw terminal plane") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordActivity.kt new file mode 100644 index 0000000000..8d1a80fb0c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordActivity.kt @@ -0,0 +1,56 @@ +package org.thoughtcrime.securesms.recoverypassword + +import android.os.Bundle +import androidx.activity.viewModels +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import network.loki.messenger.R +import org.thoughtcrime.securesms.BaseActionBarActivity +import org.thoughtcrime.securesms.showSessionDialog +import org.thoughtcrime.securesms.ui.setComposeContent + +class RecoveryPasswordActivity : BaseActionBarActivity() { + + private val viewModel: RecoveryPasswordViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + supportActionBar!!.title = resources.getString(R.string.sessionRecoveryPassword) + + setComposeContent { + val mnemonic by viewModel.mnemonic.collectAsState("") + val seed by viewModel.seed.collectAsState(null) + + RecoveryPasswordScreen( + mnemonic = mnemonic, + seed = seed, + copyMnemonic = viewModel::copyMnemonic, + onHide = ::onHide + ) + } + } + + private fun onHide() { + showSessionDialog { + title(R.string.recoveryPasswordHidePermanently) + htmlText(R.string.recoveryPasswordHidePermanentlyDescription1) + destructiveButton(R.string.continue_2, R.string.AccessibilityId_continue) { onHideConfirm() } + cancelButton() + } + } + + private fun onHideConfirm() { + showSessionDialog { + title(R.string.recoveryPasswordHidePermanently) + text(R.string.recoveryPasswordHidePermanentlyDescription2) + cancelButton() + destructiveButton( + R.string.yes, + contentDescription = R.string.AccessibilityId_confirm_button + ) { + viewModel.permanentlyHidePassword() + finish() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordViewModel.kt new file mode 100644 index 0000000000..0ad207cd23 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordViewModel.kt @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.recoverypassword + +import android.app.Application +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.session.libsession.utilities.AppTextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.crypto.MnemonicCodec +import org.session.libsignal.utilities.hexEncodedPrivateKey +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil +import org.thoughtcrime.securesms.crypto.MnemonicUtilities +import javax.inject.Inject + +@HiltViewModel +class RecoveryPasswordViewModel @Inject constructor( + private val application: Application +): AndroidViewModel(application) { + val prefs = AppTextSecurePreferences(application) + + val seed = MutableStateFlow(null) + val mnemonic = seed.filterNotNull() + .map { MnemonicCodec { MnemonicUtilities.loadFileContents(application, it) }.encode(it, MnemonicCodec.Language.Configuration.english) } + .stateIn(viewModelScope, SharingStarted.Eagerly, "") + + fun permanentlyHidePassword() { + prefs.setHidePassword(true) + } + + fun copyMnemonic() { + prefs.setHasViewedSeed(true) + ClipData.newPlainText("Seed", mnemonic.value) + .let(application.clipboard::setPrimaryClip) + } + + init { + viewModelScope.launch(Dispatchers.IO) { + seed.emit(IdentityKeyUtil.retrieve(application, IdentityKeyUtil.LOKI_SEED) + ?: IdentityKeyUtil.getIdentityKeyPair(application).hexEncodedPrivateKey) // Legacy account + } + } +} + +private val Context.clipboard get() = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 8a7a2dfd0f..2a00440dda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -99,7 +99,7 @@ class DefaultConversationRepository @Inject constructor( if (!recipient.isOpenGroupInboxRecipient) return null return Recipient.from( context, - Address.fromSerialized(GroupUtil.getDecodedOpenGroupInboxSessionId(recipient.address.serialize())), + Address.fromSerialized(GroupUtil.getDecodedOpenGroupInboxAccountId(recipient.address.serialize())), false ) } @@ -281,9 +281,9 @@ class DefaultConversationRepository @Inject constructor( override suspend fun banUser(threadId: Long, recipient: Recipient): ResultOf = suspendCoroutine { continuation -> - val sessionID = recipient.address.toString() + val accountID = recipient.address.toString() val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! - OpenGroupApi.ban(sessionID, openGroup.room, openGroup.server) + OpenGroupApi.ban(accountID, openGroup.room, openGroup.server) .success { continuation.resume(ResultOf.Success(Unit)) }.fail { error -> @@ -293,11 +293,11 @@ class DefaultConversationRepository @Inject constructor( override suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): ResultOf = suspendCoroutine { continuation -> - // Note: This sessionId could be the blinded Id - val sessionID = recipient.address.toString() + // Note: This accountId could be the blinded Id + val accountID = recipient.address.toString() val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! - OpenGroupApi.banAndDeleteAll(sessionID, openGroup.room, openGroup.server) + OpenGroupApi.banAndDeleteAll(accountID, openGroup.room, openGroup.server) .success { continuation.resume(ResultOf.Success(Unit)) }.fail { error -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java index f2adbf2349..204b63f802 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java @@ -72,10 +72,6 @@ public class SearchRepository { public void query(@NonNull String query, @NonNull Callback callback) { // If the sanitized search is empty then abort without search String cleanQuery = sanitizeQuery(query).trim(); - if (cleanQuery.isEmpty()) { - callback.onResult(SearchResult.EMPTY); - return; - } executor.execute(() -> { Stopwatch timer = new Stopwatch("FtsQuery"); @@ -110,7 +106,7 @@ public class SearchRepository { }); } - private Pair, List> queryContacts(String query) { + public Pair, List> queryContacts(String query) { Cursor contacts = contactDatabase.queryContactsByName(query); List
contactList = new ArrayList<>(); List contactStrings = new ArrayList<>(); @@ -118,10 +114,10 @@ public class SearchRepository { while (contacts.moveToNext()) { try { Contact contact = contactDatabase.contactFromCursor(contacts); - String contactSessionId = contact.getSessionID(); - Address address = Address.fromSerialized(contactSessionId); + String contactAccountId = contact.getAccountID(); + Address address = Address.fromSerialized(contactAccountId); contactList.add(address); - contactStrings.add(contactSessionId); + contactStrings.add(contactAccountId); } catch (Exception e) { Log.e("Loki", "Error building Contact from cursor in query", e); } @@ -211,7 +207,7 @@ public class SearchRepository { @Override public Contact build(@NonNull Cursor cursor) { ThreadRecord threadRecord = threadDb.readerFor(cursor).getCurrent(); - Contact contact = contactDb.getContactWithSessionID(threadRecord.getRecipient().getAddress().serialize()); + Contact contact = contactDb.getContactWithAccountID(threadRecord.getRecipient().getAddress().serialize()); if (contact == null) { contact = new Contact(threadRecord.getRecipient().getAddress().serialize()); contact.setThreadID(threadRecord.getThreadId()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt index 8b1975865d..d6383ab7fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt @@ -5,7 +5,7 @@ import network.loki.messenger.libsession_util.util.UserPic import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob -import org.session.libsession.messaging.utilities.SessionId +import org.session.libsession.messaging.utilities.AccountId import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient @@ -18,10 +18,10 @@ class ProfileManager(private val context: Context, private val configFactory: Co override fun setNickname(context: Context, recipient: Recipient, nickname: String?) { if (recipient.isLocalNumber) return - val sessionID = recipient.address.serialize() + val accountID = recipient.address.serialize() val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() - var contact = contactDatabase.getContactWithSessionID(sessionID) - if (contact == null) contact = Contact(sessionID) + var contact = contactDatabase.getContactWithAccountID(accountID) + if (contact == null) contact = Contact(accountID) contact.threadID = DatabaseComponent.get(context).storage().getThreadId(recipient.address) if (contact.nickname != nickname) { contact.nickname = nickname @@ -33,10 +33,10 @@ class ProfileManager(private val context: Context, private val configFactory: Co override fun setName(context: Context, recipient: Recipient, name: String?) { // New API if (recipient.isLocalNumber) return - val sessionID = recipient.address.serialize() + val accountID = recipient.address.serialize() val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() - var contact = contactDatabase.getContactWithSessionID(sessionID) - if (contact == null) contact = Contact(sessionID) + var contact = contactDatabase.getContactWithAccountID(accountID) + if (contact == null) contact = Contact(accountID) contact.threadID = DatabaseComponent.get(context).storage().getThreadId(recipient.address) if (contact.name != name) { contact.name = name @@ -67,10 +67,10 @@ class ProfileManager(private val context: Context, private val configFactory: Co newProfileKey = profileKey, newProfilePicture = profilePictureURL ) - val sessionID = recipient.address.serialize() + val accountID = recipient.address.serialize() val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() - var contact = contactDatabase.getContactWithSessionID(sessionID) - if (contact == null) contact = Contact(sessionID) + var contact = contactDatabase.getContactWithAccountID(accountID) + if (contact == null) contact = Contact(accountID) contact.threadID = DatabaseComponent.get(context).storage().getThreadId(recipient.address) if (!contact.profilePictureEncryptionKey.contentEquals(profileKey) || contact.profilePictureURL != profilePictureURL) { contact.profilePictureEncryptionKey = profileKey @@ -91,10 +91,10 @@ class ProfileManager(private val context: Context, private val configFactory: Co override fun contactUpdatedInternal(contact: Contact): String? { val contactConfig = configFactory.contacts ?: return null - if (contact.sessionID == TextSecurePreferences.getLocalNumber(context)) return null - val sessionId = SessionId(contact.sessionID) - if (sessionId.prefix != IdPrefix.STANDARD) return null // only internally store standard session IDs - contactConfig.upsertContact(contact.sessionID) { + if (contact.accountID == TextSecurePreferences.getLocalNumber(context)) return null + val accountId = AccountId(contact.accountID) + if (accountId.prefix != IdPrefix.STANDARD) return null // only internally store standard account IDs + contactConfig.upsertContact(contact.accountID) { this.name = contact.name.orEmpty() this.nickname = contact.nickname.orEmpty() val url = contact.profilePictureURL @@ -108,7 +108,7 @@ class ProfileManager(private val context: Context, private val configFactory: Co if (contactConfig.needsPush()) { ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) } - return contactConfig.get(contact.sessionID)?.hashCode()?.toString() + return contactConfig.get(contact.accountID)?.hashCode()?.toString() } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt new file mode 100644 index 0000000000..121748809f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt @@ -0,0 +1,127 @@ +package org.thoughtcrime.securesms.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +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.graphics.RectangleShape +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.color.LocalColors + +class DialogButtonModel( + val text: GetString, + val contentDescription: GetString = text, + val color: Color = Color.Unspecified, + val dismissOnClick: Boolean = true, + val onClick: () -> Unit = {}, +) + +@Composable +fun AlertDialog( + onDismissRequest: () -> Unit, + title: String? = null, + text: String? = null, + content: @Composable () -> Unit = {}, + buttons: List? = null +) { + androidx.compose.material.AlertDialog( + onDismissRequest, + shape = MaterialTheme.shapes.small, + backgroundColor = LocalColors.current.backgroundSecondary, + buttons = { + Box { + IconButton( + onClick = onDismissRequest, + modifier = Modifier.align(Alignment.TopEnd) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_dialog_x), + tint = LocalColors.current.text, + contentDescription = "back" + ) + } + + Column(modifier = Modifier.fillMaxWidth()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(top = LocalDimensions.current.smallItemSpacing) + .padding(horizontal = LocalDimensions.current.smallItemSpacing) + ) { + title?.let { + Text( + it, + textAlign = TextAlign.Center, + style = h7, + modifier = Modifier.padding(bottom = LocalDimensions.current.xxsItemSpacing) + ) + } + text?.let { + Text( + it, + textAlign = TextAlign.Center, + style = large, + modifier = Modifier.padding(bottom = LocalDimensions.current.xxsItemSpacing) + ) + } + content() + } + buttons?.takeIf { it.isNotEmpty() }?.let { + Row(Modifier.height(IntrinsicSize.Min)) { + it.forEach { + DialogButton( + text = it.text(), + modifier = Modifier + .fillMaxHeight() + .contentDescription(it.contentDescription()) + .weight(1f), + color = it.color + ) { + it.onClick() + if (it.dismissOnClick) onDismissRequest() + } + } + } + } + } + } + } + ) +} + +@Composable +fun DialogButton(text: String, modifier: Modifier, color: Color = Color.Unspecified, onClick: () -> Unit) { + TextButton( + modifier = modifier, + shape = RectangleShape, + onClick = onClick + ) { + Text( + text, + color = color.takeOrElse { LocalColors.current.text }, + style = largeBold, + textAlign = TextAlign.Center, + modifier = Modifier.padding( + top = LocalDimensions.current.smallItemSpacing, + bottom = LocalDimensions.current.itemSpacing + ) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt index 0b7b6d6b4c..e3ffff66d0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt @@ -2,47 +2,182 @@ package org.thoughtcrime.securesms.ui import androidx.annotation.DrawableRes import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Card +import androidx.compose.material.ContentAlpha import androidx.compose.material.Icon import androidx.compose.material.IconButton +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.LocalContentColor import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.color.LocalColors +import kotlin.math.absoluteValue +import kotlin.math.sign @OptIn(ExperimentalFoundationApi::class) @Composable fun BoxScope.HorizontalPagerIndicator(pagerState: PagerState) { if (pagerState.pageCount >= 2) Card( - shape = RoundedCornerShape(50.dp), + shape = pillShape, backgroundColor = Color.Black.copy(alpha = 0.4f), modifier = Modifier .align(Alignment.BottomCenter) .padding(8.dp) ) { Box(modifier = Modifier.padding(8.dp)) { - com.google.accompanist.pager.HorizontalPagerIndicator( + ClickableHorizontalPagerIndicator( pagerState = pagerState, pageCount = pagerState.pageCount, activeColor = Color.White, - inactiveColor = classicDarkColors[5]) + inactiveColor = LocalColors.current.textSecondary) } } } +internal interface PagerStateBridge { + val currentPage: Int + val currentPageOffset: Float +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ClickableHorizontalPagerIndicator( + pagerState: PagerState, + pageCount: Int, + modifier: Modifier = Modifier, + pageIndexMapping: (Int) -> Int = { it }, + activeColor: Color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current), + inactiveColor: Color = activeColor.copy(ContentAlpha.disabled), + indicatorWidth: Dp = 8.dp, + indicatorHeight: Dp = indicatorWidth, + spacing: Dp = indicatorWidth, + indicatorShape: Shape = CircleShape, +) { + val scope = rememberCoroutineScope() + + val stateBridge = remember(pagerState) { + object : PagerStateBridge { + override val currentPage: Int + get() = pagerState.currentPage + override val currentPageOffset: Float + get() = pagerState.currentPageOffsetFraction + } + } + + HorizontalPagerIndicator( + pagerState = stateBridge, + pageCount = pageCount, + modifier = modifier, + pageIndexMapping = pageIndexMapping, + activeColor = activeColor, + inactiveColor = inactiveColor, + indicatorHeight = indicatorHeight, + indicatorWidth = indicatorWidth, + spacing = spacing, + indicatorShape = indicatorShape, + ) { + scope.launch { + pagerState.animateScrollToPage(it) + } + } +} + +@Composable +private fun HorizontalPagerIndicator( + pagerState: PagerStateBridge, + pageCount: Int, + modifier: Modifier = Modifier, + pageIndexMapping: (Int) -> Int = { it }, + activeColor: Color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current), + inactiveColor: Color = activeColor.copy(ContentAlpha.disabled), + indicatorWidth: Dp = 8.dp, + indicatorHeight: Dp = indicatorWidth, + spacing: Dp = indicatorWidth, + indicatorShape: Shape = CircleShape, + onIndicatorClick: (Int) -> Unit, +) { + + val indicatorWidthPx = LocalDensity.current.run { indicatorWidth.roundToPx() } + val spacingPx = LocalDensity.current.run { spacing.roundToPx() } + + Box( + modifier = modifier, + contentAlignment = Alignment.CenterStart + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalAlignment = Alignment.CenterVertically, + ) { + + repeat(pageCount) { + Box( + modifier = Modifier + .size(width = indicatorWidth, height = indicatorHeight) + .clip(indicatorShape) + .background(color = inactiveColor) + .clickable { onIndicatorClick(it) } //modified here + ) + } + } + + Box( + Modifier + .offset { + val position = pageIndexMapping(pagerState.currentPage) + val offset = pagerState.currentPageOffset + val next = pageIndexMapping(pagerState.currentPage + offset.sign.toInt()) + val scrollPosition = ((next - position) * offset.absoluteValue + position) + .coerceIn( + 0f, + (pageCount - 1) + .coerceAtLeast(0) + .toFloat() + ) + + IntOffset( + x = ((spacingPx + indicatorWidthPx) * scrollPosition).toInt(), + y = 0 + ) + } + .size(width = indicatorWidth, height = indicatorHeight) + .then( + if (pageCount > 0) Modifier.background( + color = activeColor, + shape = indicatorShape, + ) + else Modifier + ) + ) + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable fun RowScope.CarouselPrevButton(pagerState: PagerState) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Colors.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Colors.kt deleted file mode 100644 index 55bc1be62e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Colors.kt +++ /dev/null @@ -1,63 +0,0 @@ -package org.thoughtcrime.securesms.ui - -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color - -val colorDestructive = Color(0xffFF453A) - -const val classicDark0 = 0xff111111 -const val classicDark1 = 0xff1B1B1B -const val classicDark2 = 0xff2D2D2D -const val classicDark3 = 0xff414141 -const val classicDark4 = 0xff767676 -const val classicDark5 = 0xffA1A2A1 -const val classicDark6 = 0xffFFFFFF - -const val classicLight0 = 0xff000000 -const val classicLight1 = 0xff6D6D6D -const val classicLight2 = 0xffA1A2A1 -const val classicLight3 = 0xffDFDFDF -const val classicLight4 = 0xffF0F0F0 -const val classicLight5 = 0xffF9F9F9 -const val classicLight6 = 0xffFFFFFF - -const val oceanDark0 = 0xff000000 -const val oceanDark1 = 0xff1A1C28 -const val oceanDark2 = 0xff252735 -const val oceanDark3 = 0xff2B2D40 -const val oceanDark4 = 0xff3D4A5D -const val oceanDark5 = 0xffA6A9CE -const val oceanDark6 = 0xff5CAACC -const val oceanDark7 = 0xffFFFFFF - -const val oceanLight0 = 0xff000000 -const val oceanLight1 = 0xff19345D -const val oceanLight2 = 0xff6A6E90 -const val oceanLight3 = 0xff5CAACC -const val oceanLight4 = 0xffB3EDF2 -const val oceanLight5 = 0xffE7F3F4 -const val oceanLight6 = 0xffECFAFB -const val oceanLight7 = 0xffFCFFFF - -val ocean_accent = Color(0xff57C9FA) - -val oceanLights = arrayOf(oceanLight0, oceanLight1, oceanLight2, oceanLight3, oceanLight4, oceanLight5, oceanLight6, oceanLight7) -val oceanDarks = arrayOf(oceanDark0, oceanDark1, oceanDark2, oceanDark3, oceanDark4, oceanDark5, oceanDark6, oceanDark7) -val classicLights = arrayOf(classicLight0, classicLight1, classicLight2, classicLight3, classicLight4, classicLight5, classicLight6) -val classicDarks = arrayOf(classicDark0, classicDark1, classicDark2, classicDark3, classicDark4, classicDark5, classicDark6) - -val oceanLightColors = oceanLights.map(::Color) -val oceanDarkColors = oceanDarks.map(::Color) -val classicLightColors = classicLights.map(::Color) -val classicDarkColors = classicDarks.map(::Color) - -val blackAlpha40 = Color.Black.copy(alpha = 0.4f) - -@Composable -fun transparentButtonColors() = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent) - -@Composable -fun destructiveButtonColors() = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent, contentColor = colorDestructive) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 6c223a45f2..31ce715a8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -1,15 +1,19 @@ package org.thoughtcrime.securesms.ui import androidx.annotation.DrawableRes -import androidx.compose.foundation.BorderStroke +import androidx.annotation.StringRes +import androidx.appcompat.content.res.AppCompatResources +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image import androidx.compose.foundation.ScrollState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -17,42 +21,56 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ButtonColors -import androidx.compose.material.ButtonDefaults import androidx.compose.material.Card -import androidx.compose.material.Colors import androidx.compose.material.Icon +import androidx.compose.material.LocalContentColor import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedButton import androidx.compose.material.RadioButton import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import network.loki.messenger.R import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.runIf import org.thoughtcrime.securesms.components.ProfilePictureView import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.OptionsCard +import org.thoughtcrime.securesms.ui.color.LocalColors +import org.thoughtcrime.securesms.ui.color.divider +import org.thoughtcrime.securesms.ui.color.radioButtonColors +import org.thoughtcrime.securesms.ui.color.transparentButtonColors +import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator import kotlin.math.min +import kotlin.math.roundToInt interface Callbacks { fun onSetClick(): Any? @@ -75,7 +93,10 @@ data class RadioOption( @Composable fun OptionsCard(card: OptionsCard, callbacks: Callbacks) { - Text(text = card.title()) + Text( + card.title(), + style = base + ) CellNoMargin { LazyColumn( modifier = Modifier.heightIn(max = 5000.dp) @@ -88,39 +109,129 @@ fun OptionsCard(card: OptionsCard, callbacks: Callbacks) { } } +@Composable +fun LargeItemButtonWithDrawable( + @StringRes textId: Int, + @DrawableRes icon: Int, + modifier: Modifier = Modifier, + colors: ButtonColors = transparentButtonColors(), + onClick: () -> Unit +) { + ItemButtonWithDrawable(textId, icon, modifier.heightIn(min = LocalDimensions.current.minLargeItemButtonHeight), h8, colors, onClick) +} +@Composable +fun ItemButtonWithDrawable( + @StringRes textId: Int, + @DrawableRes icon: Int, + modifier: Modifier = Modifier, + textStyle: TextStyle = xl, + colors: ButtonColors = transparentButtonColors(), + onClick: () -> Unit +) { + val context = LocalContext.current + + ItemButton( + text = stringResource(textId), + modifier = modifier, + icon = { + Image( + painter = rememberDrawablePainter(drawable = AppCompatResources.getDrawable(context, icon)), + contentDescription = null, + modifier = Modifier.align(Alignment.Center) + ) + }, + textStyle = textStyle, + colors = colors, + onClick = onClick + ) +} + +@Composable +fun LargeItemButton( + @StringRes textId: Int, + @DrawableRes icon: Int, + modifier: Modifier = Modifier, + colors: ButtonColors = transparentButtonColors(), + onClick: () -> Unit +) { + ItemButton(textId, icon, modifier.heightIn(min = LocalDimensions.current.minLargeItemButtonHeight), h8, colors, onClick) +} + +/** + * Courtesy [ItemButton] implementation that takes a [DrawableRes] for the [icon] + */ +@Composable +fun ItemButton( + @StringRes textId: Int, + @DrawableRes icon: Int, + modifier: Modifier = Modifier, + textStyle: TextStyle = xl, + colors: ButtonColors = transparentButtonColors(), + onClick: () -> Unit +) { + ItemButton( + text = stringResource(textId), + modifier = modifier, + icon = { + Icon( + painter = painterResource(id = icon), + contentDescription = null, + modifier = Modifier.align(Alignment.Center) + ) + }, + textStyle = textStyle, + colors = colors, + onClick = onClick + ) +} + +/** +* Base [ItemButton] implementation. + * + * A button to be used in a list of buttons, usually in a [Cell] or [Card] +*/ @Composable fun ItemButton( text: String, - @DrawableRes icon: Int, + icon: @Composable BoxScope.() -> Unit, + modifier: Modifier = Modifier, + textStyle: TextStyle = xl, colors: ButtonColors = transparentButtonColors(), - contentDescription: String = text, onClick: () -> Unit ) { TextButton( - modifier = Modifier - .fillMaxWidth() - .height(60.dp), + modifier = modifier.fillMaxWidth(), colors = colors, onClick = onClick, shape = RectangleShape, ) { - Box(modifier = Modifier - .width(80.dp) - .fillMaxHeight()) { - Icon( - painter = painterResource(id = icon), - contentDescription = contentDescription, - modifier = Modifier.align(Alignment.Center) - ) + Box( + modifier = Modifier + .width(80.dp) + .wrapContentHeight() + .align(Alignment.CenterVertically) + ) { + icon() } - Text(text, modifier = Modifier.fillMaxWidth()) + Text( + text, + Modifier + .fillMaxWidth() + .padding(vertical = LocalDimensions.current.xsItemSpacing) + .align(Alignment.CenterVertically), + style = textStyle + ) } } @Composable -fun Cell(content: @Composable () -> Unit) { - CellWithPaddingAndMargin(padding = 0.dp) { content() } +fun Cell( + padding: Dp = 0.dp, + margin: Dp = LocalDimensions.current.margin, + content: @Composable () -> Unit +) { + CellWithPaddingAndMargin(padding, margin) { content() } } @Composable fun CellNoMargin(content: @Composable () -> Unit) { @@ -129,13 +240,13 @@ fun CellNoMargin(content: @Composable () -> Unit) { @Composable fun CellWithPaddingAndMargin( - padding: Dp = 24.dp, - margin: Dp = 32.dp, + padding: Dp = LocalDimensions.current.smallMargin, + margin: Dp = LocalDimensions.current.margin, content: @Composable () -> Unit ) { Card( - backgroundColor = MaterialTheme.colors.cellColor, - shape = RoundedCornerShape(16.dp), + backgroundColor = LocalColors.current.backgroundSecondary, + shape = MaterialTheme.shapes.medium, elevation = 0.dp, modifier = Modifier .wrapContentHeight() @@ -148,12 +259,13 @@ fun CellWithPaddingAndMargin( @Composable fun TitledRadioButton(option: RadioOption, onClick: () -> Unit) { + val color = if (option.enabled) LocalColors.current.text else LocalColors.current.disabled Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallItemSpacing), modifier = Modifier .runIf(option.enabled) { clickable { if (!option.selected) onClick() } } .heightIn(min = 60.dp) - .padding(horizontal = 32.dp) + .padding(horizontal = LocalDimensions.current.margin) .contentDescription(option.contentDescription) ) { Column(modifier = Modifier @@ -162,14 +274,14 @@ fun TitledRadioButton(option: RadioOption, onClick: () -> Unit) { Column { Text( text = option.title(), - fontSize = 16.sp, - modifier = Modifier.alpha(if (option.enabled) 1f else 0.5f) + style = large, + color = color ) option.subtitle?.let { Text( text = it(), - fontSize = 11.sp, - modifier = Modifier.alpha(if (option.enabled) 1f else 0.5f) + style = extraSmall, + color = color ) } } @@ -177,40 +289,33 @@ fun TitledRadioButton(option: RadioOption, onClick: () -> Unit) { RadioButton( selected = option.selected, onClick = null, - enabled = option.enabled, modifier = Modifier .height(26.dp) - .align(Alignment.CenterVertically) + .align(Alignment.CenterVertically), + enabled = option.enabled, + colors = LocalColors.current.radioButtonColors() ) } } @Composable fun Modifier.contentDescription(text: GetString?): Modifier { - val context = LocalContext.current - return text?.let { semantics { contentDescription = it(context) } } ?: this + return text?.let { + val context = LocalContext.current + semantics { contentDescription = it(context) } + } ?: this } @Composable -fun OutlineButton(text: GetString, contentDescription: GetString? = text, modifier: Modifier = Modifier, onClick: () -> Unit) { - OutlinedButton( - modifier = modifier.size(108.dp, 34.dp) - .contentDescription(contentDescription), - onClick = onClick, - border = BorderStroke(1.dp, LocalExtraColors.current.prominentButtonColor), - shape = RoundedCornerShape(50), // = 50% percent - colors = ButtonDefaults.outlinedButtonColors( - contentColor = LocalExtraColors.current.prominentButtonColor, - backgroundColor = MaterialTheme.colors.background - ) - ){ - Text(text = text()) - } +fun Modifier.contentDescription(@StringRes id: Int?): Modifier { + val context = LocalContext.current + return id?.let { semantics { contentDescription = context.getString(it) } } ?: this } -private val Colors.cellColor: Color - @Composable - get() = LocalExtraColors.current.settingsBackground +@Composable +fun Modifier.contentDescription(text: String?): Modifier { + return text?.let { semantics { contentDescription = it } } ?: this +} fun Modifier.fadingEdges( scrollState: ScrollState, @@ -251,9 +356,11 @@ fun Modifier.fadingEdges( ) @Composable -fun Divider() { +fun Divider(modifier: Modifier = Modifier, startIndent: Dp = 0.dp) { androidx.compose.material.Divider( - modifier = Modifier.padding(horizontal = 16.dp), + modifier = modifier.padding(horizontal = LocalDimensions.current.xsMargin), + color = LocalColors.current.divider, + startIndent = startIndent ) } @@ -274,3 +381,81 @@ fun RowScope.Avatar(recipient: Recipient) { ) } } + +@Composable +fun ProgressArc(progress: Float, modifier: Modifier = Modifier) { + val text = (progress * 100).roundToInt() + + Box(modifier = modifier) { + Arc(percentage = progress, modifier = Modifier.align(Alignment.Center)) + Text( + "${text}%", + color = Color.White, + modifier = Modifier.align(Alignment.Center), + style = h2 + ) + } +} + +@Composable +fun Arc( + modifier: Modifier = Modifier, + percentage: Float = 0.25f, + fillColor: Color = LocalColors.current.primary, + backgroundColor: Color = LocalColors.current.borders, + strokeWidth: Dp = 18.dp, + sweepAngle: Float = 310f, + startAngle: Float = (360f - sweepAngle) / 2 + 90f +) { + Canvas( + modifier = modifier + .padding(strokeWidth) + .size(186.dp) + ) { + // Background Line + drawArc( + color = backgroundColor, + startAngle, + sweepAngle, + false, + style = Stroke(strokeWidth.toPx(), cap = StrokeCap.Round), + size = Size(size.width, size.height) + ) + + drawArc( + color = fillColor, + startAngle, + percentage * sweepAngle, + false, + style = Stroke(strokeWidth.toPx(), cap = StrokeCap.Round), + size = Size(size.width, size.height) + ) + } +} + +@Composable +fun RowScope.SessionShieldIcon() { + Icon( + painter = painterResource(R.drawable.session_shield), + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterVertically) + .wrapContentSize(unbounded = true) + ) +} + +@Composable +fun LaunchedEffectAsync(block: suspend CoroutineScope.() -> Unit) { + val scope = rememberCoroutineScope() + LaunchedEffect(Unit) { scope.launch(Dispatchers.IO) { block() } } +} + +@Composable +fun LoadingArcOr(loading: Boolean, content: @Composable () -> Unit) { + AnimatedVisibility(loading) { + SmallCircularProgressIndicator(color = LocalContentColor.current) + } + AnimatedVisibility(!loading) { + content() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Dimensions.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Dimensions.kt new file mode 100644 index 0000000000..0a04880e8a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Dimensions.kt @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.ui + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +val LocalDimensions = staticCompositionLocalOf { Dimensions() } + +data class Dimensions( + val xxxsItemSpacing: Dp = 4.dp, + val xxsItemSpacing: Dp = 8.dp, + val xsItemSpacing: Dp = 12.dp, + val smallItemSpacing: Dp = 16.dp, + val itemSpacing: Dp = 24.dp, + + val xxxsMargin: Dp = 8.dp, + val xxsMargin: Dp = 12.dp, + val xsMargin: Dp = 16.dp, + val smallMargin: Dp = 24.dp, + val margin: Dp = 32.dp, + val onboardingMargin: Dp = 36.dp, + val largeMargin: Dp = 64.dp, + val homeEmptyViewMargin: Dp = 50.dp, + + val dividerIndent: Dp = 80.dp, + val appBarHeight: Dp = 64.dp, + val minScrollableViewHeight: Dp = 200.dp, + val minLargeItemButtonHeight: Dp = 60.dp, + + val indicatorHeight: Dp = 4.dp, + val borderStroke: Dp = 1.dp +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/SessionTypography.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/SessionTypography.kt new file mode 100644 index 0000000000..426c6f8dc4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/SessionTypography.kt @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.ui + +import androidx.compose.material.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontFamily.Companion.Monospace +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp + +fun boldStyle(size: TextUnit) = TextStyle.Default.copy( + fontSize = size, + lineHeight = size * 1.2, + fontWeight = FontWeight.Bold, +) + +fun defaultStyle(size: TextUnit, fontFamily: FontFamily? = TextStyle.Default.fontFamily) = TextStyle.Default.copy( + fontSize = size, + lineHeight = size * 1.2, + fontFamily = fontFamily +) + +val xl = defaultStyle(18.sp) + +val large = defaultStyle(16.sp) +val largeBold = boldStyle(16.sp) + +val base = defaultStyle(14.sp) +val baseBold = boldStyle(14.sp) +val baseMonospace = defaultStyle(14.sp, fontFamily = Monospace) + +val small = defaultStyle(12.sp) +val smallBold = boldStyle(12.sp) +val smallMonospace = defaultStyle(12.sp, fontFamily = Monospace) + +val extraSmall = defaultStyle(11.sp) +val extraSmallBold = boldStyle(11.sp) +val extraSmallMonospace = defaultStyle(11.sp, fontFamily = Monospace) + +val fine = defaultStyle(9.sp) + +val h1 = boldStyle(36.sp) +val h2 = boldStyle(32.sp) +val h3 = boldStyle(29.sp) +val h4 = boldStyle(26.sp) +val h5 = boldStyle(23.sp) +val h6 = boldStyle(20.sp) +val h7 = boldStyle(18.sp) +val h8 = boldStyle(16.sp) +val h9 = boldStyle(14.sp) + +val sessionTypography = Typography( + h1 = h1, + h2 = h2, + h3 = h3, + h4 = h4, + h5 = h5, + h6 = h6, +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Strings.kt similarity index 95% rename from app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt rename to app/src/main/java/org/thoughtcrime/securesms/ui/Strings.kt index e472209005..91500da7b2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Strings.kt @@ -40,7 +40,6 @@ sealed class GetString { data class FromMap(val value: T, val function: (Context, T) -> String): GetString() { @Composable override fun string(): String = function(LocalContext.current, value) - override fun string(context: Context): String = function(context, value) } } @@ -48,7 +47,7 @@ sealed class GetString { fun GetString(@StringRes resId: Int) = GetString.FromResId(resId) fun GetString(string: String) = GetString.FromString(string) fun GetString(function: (Context) -> String) = GetString.FromFun(function) -fun GetString(value: T, function: (Context, T) -> String) = GetString.FromMap(value, function) +fun GetString(value: T, function: Context.(T) -> String) = GetString.FromMap(value, function) fun GetString(duration: Duration) = GetString.FromMap(duration, ExpirationUtil::getExpirationDisplayValue) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt index c1ac53baa8..22050a2764 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt @@ -1,78 +1,103 @@ package org.thoughtcrime.securesms.ui import android.content.Context -import androidx.annotation.AttrRes -import androidx.appcompat.view.ContextThemeWrapper import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.material.LocalContentColor import androidx.compose.material.MaterialTheme +import androidx.compose.material.Shapes import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import com.google.accompanist.themeadapter.appcompat.AppCompatTheme -import com.google.android.material.color.MaterialColors -import network.loki.messenger.R - -val LocalExtraColors = staticCompositionLocalOf { error("No Custom Attribute value provided") } - - -data class ExtraColors( - val settingsBackground: Color, - val prominentButtonColor: Color -) +import androidx.compose.ui.unit.dp +import org.session.libsession.utilities.AppTextSecurePreferences +import org.thoughtcrime.securesms.ui.color.ClassicDark +import org.thoughtcrime.securesms.ui.color.ClassicLight +import org.thoughtcrime.securesms.ui.color.Colors +import org.thoughtcrime.securesms.ui.color.LocalColors +import org.thoughtcrime.securesms.ui.color.OceanDark +import org.thoughtcrime.securesms.ui.color.OceanLight +import org.thoughtcrime.securesms.ui.color.colors +import org.thoughtcrime.securesms.ui.color.textSelectionColors /** - * Converts current Theme to Compose Theme. + * Apply a Material2 compose theme based on user selections in SharedPreferences. */ @Composable -fun AppTheme( +fun SessionMaterialTheme( content: @Composable () -> Unit ) { - val extraColors = LocalContext.current.run { - ExtraColors( - settingsBackground = getColorFromTheme(R.attr.backgroundSecondary), - prominentButtonColor = getColorFromTheme(R.attr.prominentButtonColor), - ) - } + SessionMaterialTheme(LocalContext.current.colors()) { content() } +} - CompositionLocalProvider(LocalExtraColors provides extraColors) { - AppCompatTheme { +/** + * Apply a given [Colors], and our typography and shapes as a Material 2 Compose Theme. + **/ +@Composable +fun SessionMaterialTheme( + colors: Colors, + content: @Composable () -> Unit +) { + MaterialTheme( + colors = colors.toMaterialColors(), + typography = sessionTypography, + shapes = sessionShapes, + ) { + CompositionLocalProvider( + LocalColors provides colors, + LocalContentColor provides colors.text, + LocalTextSelectionColors provides colors.textSelectionColors, + ) { content() } } } -fun Context.getColorFromTheme(@AttrRes attr: Int, defaultValue: Int = 0x0): Color = - MaterialColors.getColor(this, attr, defaultValue).let(::Color) +private fun Colors.toMaterialColors() = androidx.compose.material.Colors( + primary = background, + primaryVariant = backgroundSecondary, + secondary = background, + secondaryVariant = background, + background = background, + surface = background, + error = danger, + onPrimary = text, + onSecondary = text, + onBackground = text, + onSurface = text, + onError = text, + isLight = isLight +) + +@Composable private fun Context.colors() = AppTextSecurePreferences(this).colors() + +val pillShape = RoundedCornerShape(percent = 50) +val buttonShape = pillShape + +val sessionShapes = Shapes( + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(16.dp) +) /** - * Set the theme and a background for Compose Previews. + * Set the Material 2 theme and a background for Compose Previews. */ @Composable fun PreviewTheme( - themeResId: Int, + colors: Colors = LocalColors.current, content: @Composable () -> Unit ) { - CompositionLocalProvider( - LocalContext provides ContextThemeWrapper(LocalContext.current, themeResId) - ) { - AppTheme { - Box(modifier = Modifier.background(color = MaterialTheme.colors.background)) { - content() - } + SessionMaterialTheme(colors) { + Box(modifier = Modifier.background(color = LocalColors.current.background)) { + content() } } } -class ThemeResPreviewParameterProvider : PreviewParameterProvider { - override val values = sequenceOf( - R.style.Classic_Dark, - R.style.Classic_Light, - R.style.Ocean_Dark, - R.style.Ocean_Light, - ) +class SessionColorsParameterProvider : PreviewParameterProvider { + override val values = sequenceOf(ClassicDark(), ClassicLight(), OceanDark(), OceanLight()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt new file mode 100644 index 0000000000..d7aaf5430c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.ui + +import android.app.Activity +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment + +fun Activity.setComposeContent(content: @Composable () -> Unit) { + ComposeView(this) + .apply { setThemedContent(content) } + .let(::setContentView) +} + +fun Fragment.createThemedComposeView(content: @Composable () -> Unit): ComposeView = requireContext().createThemedComposeView(content) +fun Context.createThemedComposeView(content: @Composable () -> Unit): ComposeView = ComposeView(this).apply { + setThemedContent(content) +} + +fun ComposeView.setThemedContent(content: @Composable () -> Unit) = setContent { + SessionMaterialTheme { + content() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/color/ColorDefs.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/color/ColorDefs.kt new file mode 100644 index 0000000000..3e23985af3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/color/ColorDefs.kt @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms.ui.color + +import androidx.compose.ui.graphics.Color + +val classicDark0 = Color.Black +val classicDark1 = Color(0xff1B1B1B) +val classicDark2 = Color(0xff2D2D2D) +val classicDark3 = Color(0xff414141) +val classicDark4 = Color(0xff767676) +val classicDark5 = Color(0xffA1A2A1) +val classicDark6 = Color.White + +val classicLight0 = Color.Black +val classicLight1 = Color(0xff6D6D6D) +val classicLight2 = Color(0xffA1A2A1) +val classicLight3 = Color(0xffDFDFDF) +val classicLight4 = Color(0xffF0F0F0) +val classicLight5 = Color(0xffF9F9F9) +val classicLight6 = Color.White + +val oceanDark0 = Color.Black +val oceanDark1 = Color(0xff1A1C28) +val oceanDark2 = Color(0xff252735) +val oceanDark3 = Color(0xff2B2D40) +val oceanDark4 = Color(0xff3D4A5D) +val oceanDark5 = Color(0xffA6A9CE) +val oceanDark6 = Color(0xff5CAACC) +val oceanDark7 = Color.White + +val oceanLight0 = Color.Black +val oceanLight1 = Color(0xff19345D) +val oceanLight2 = Color(0xff6A6E90) +val oceanLight3 = Color(0xff5CAACC) +val oceanLight4 = Color(0xffB3EDF2) +val oceanLight5 = Color(0xffE7F3F4) +val oceanLight6 = Color(0xffECFAFB) +val oceanLight7 = Color(0xffFCFFFF) + +val primaryGreen = Color(0xFF31F196) +val primaryBlue = Color(0xFF57C9FA) +val primaryPurple = Color(0xFFC993FF) +val primaryPink = Color(0xFFFF95EF) +val primaryRed = Color(0xFFFF9C8E) +val primaryOrange = Color(0xFFFCB159) +val primaryYellow = Color(0xFFFAD657) + +val dangerDark = Color(0xFFFF3A3A) +val dangerLight = Color(0xFFE12D19) +val disabledDark = Color(0xFFA1A2A1) +val disabledLight = Color(0xFF6D6D6D) + +val blackAlpha40 = Color.Black.copy(alpha = 0.4f) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/color/Colors.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/color/Colors.kt new file mode 100644 index 0000000000..9aab573cee --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/color/Colors.kt @@ -0,0 +1,233 @@ +package org.thoughtcrime.securesms.ui.color + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.selection.TextSelectionColors +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.MaterialTheme +import androidx.compose.material.RadioButtonDefaults +import androidx.compose.material.TabRowDefaults +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.primarySurface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import org.thoughtcrime.securesms.ui.PreviewTheme +import org.thoughtcrime.securesms.ui.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.base + +val LocalColors = staticCompositionLocalOf { ClassicDark() } + +interface Colors { + val isLight: Boolean + val primary: Color + val danger: Color + val disabled: Color + val background: Color + val backgroundSecondary: Color + val text: Color + val textSecondary: Color + val borders: Color + val textBubbleSent: Color + val backgroundBubbleReceived: Color + val textBubbleReceived: Color + val backgroundBubbleSent: Color get() = primary + val qrCodeContent: Color + val qrCodeBackground: Color + val primaryButtonFill: Color + val primaryButtonFillText: Color +} + +fun Colors.text(isError: Boolean): Color = if (isError) danger else text +fun Colors.textSecondary(isError: Boolean): Color = if (isError) danger else textSecondary +fun Colors.borders(isError: Boolean): Color = if (isError) danger else borders + +val Colors.textSelectionColors get() = TextSelectionColors( + handleColor = primary, + backgroundColor = primary.copy(alpha = 0.5f) +) + +data class ClassicDark(override val primary: Color = primaryGreen): Colors { + override val isLight = false + override val danger = dangerDark + override val disabled = disabledDark + override val background = classicDark0 + override val backgroundSecondary = classicDark1 + override val text = classicDark6 + override val textSecondary = classicDark5 + override val borders = classicDark3 + override val textBubbleSent = Color.Black + override val backgroundBubbleReceived = classicDark2 + override val textBubbleReceived = Color.White + override val qrCodeContent = background + override val qrCodeBackground = text + override val primaryButtonFill = primary + override val primaryButtonFillText = Color.Black +} + +data class ClassicLight(override val primary: Color = primaryGreen): Colors { + override val isLight = true + override val danger = dangerLight + override val disabled = disabledLight + override val background = classicLight6 + override val backgroundSecondary = classicLight5 + override val text = classicLight0 + override val textSecondary = classicLight1 + override val borders = classicLight3 + override val textBubbleSent = Color.Black + override val backgroundBubbleReceived = classicLight4 + override val textBubbleReceived = classicLight4 + override val qrCodeContent = text + override val qrCodeBackground = backgroundSecondary + override val primaryButtonFill = text + override val primaryButtonFillText = Color.White +} + +data class OceanDark(override val primary: Color = primaryBlue): Colors { + override val isLight = false + override val danger = dangerDark + override val disabled = disabledDark + override val background = oceanDark2 + override val backgroundSecondary = oceanDark1 + override val text = oceanDark7 + override val textSecondary = oceanDark5 + override val borders = oceanDark4 + override val textBubbleSent = Color.Black + override val backgroundBubbleReceived = oceanDark4 + override val textBubbleReceived = oceanDark4 + override val qrCodeContent = background + override val qrCodeBackground = text + override val primaryButtonFill = primary + override val primaryButtonFillText = Color.Black +} + +data class OceanLight(override val primary: Color = primaryBlue): Colors { + override val isLight = true + override val danger = dangerLight + override val disabled = disabledLight + override val background = oceanLight7 + override val backgroundSecondary = oceanLight6 + override val text = oceanLight1 + override val textSecondary = oceanLight2 + override val borders = oceanLight3 + override val textBubbleSent = oceanLight1 + override val backgroundBubbleReceived = oceanLight4 + override val textBubbleReceived = oceanLight1 + override val qrCodeContent = text + override val qrCodeBackground = backgroundSecondary + override val primaryButtonFill = text + override val primaryButtonFillText = Color.White +} + +@Composable +fun Colors(name: String, colors: List) { + Column { + colors.forEachIndexed { i, it -> + Box(Modifier.background(it)) { + Text("$name: $i") + } + } + } +} + +@Preview +@Composable +fun PreviewThemeColors( + @PreviewParameter(SessionColorsParameterProvider::class) colors: Colors +) { + PreviewTheme(colors) { ThemeColors() } +} + +@Composable +private fun ThemeColors() { + Column { + Box(Modifier.background(MaterialTheme.colors.primary)) { + Text("primary", style = base) + } + Box(Modifier.background(MaterialTheme.colors.primaryVariant)) { + Text("primaryVariant", style = base) + } + Box(Modifier.background(MaterialTheme.colors.secondary)) { + Text("secondary", style = base) + } + Box(Modifier.background(MaterialTheme.colors.secondaryVariant)) { + Text("secondaryVariant", style = base) + } + Box(Modifier.background(MaterialTheme.colors.surface)) { + Text("surface", style = base) + } + Box(Modifier.background(MaterialTheme.colors.primarySurface)) { + Text("primarySurface", style = base) + } + Box(Modifier.background(MaterialTheme.colors.background)) { + Text("background", style = base) + } + Box(Modifier.background(MaterialTheme.colors.error)) { + Text("error", style = base) + } + } +} + +@Composable +fun Colors.outlinedTextFieldColors( + isError: Boolean +) = TextFieldDefaults.outlinedTextFieldColors( + textColor = if (isError) danger else text, + cursorColor = if (isError) danger else text, + focusedBorderColor = borders, + unfocusedBorderColor = borders, + placeholderColor = if (isError) danger else textSecondary +) + +val Colors.divider get() = text.copy(alpha = TabRowDefaults.DividerOpacity) + +@Composable +fun Colors.radioButtonColors() = RadioButtonDefaults.colors( + selectedColor = primary, + unselectedColor = text, + disabledColor = disabled +) + +@Composable +fun transparentButtonColors() = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent) + +@Composable +fun destructiveButtonColors() = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent, contentColor = LocalColors.current.danger) + + +/** + * This class holds two instances of [Colors], [light] representing the [Colors] to use when the system is in a + * light theme, and [dark] representing the [Colors] to use when the system is in a dark theme. + * + * If the user has [followSystemSettings] turned on then [light] should be equal to [dark]. + */ +data class LightDarkColors( + val light: Colors, + val dark: Colors +) { + @Composable + fun colors() = if (light == dark || isSystemInDarkTheme()) dark else light +} + +/** + * Courtesy constructor that sets [light] and [dark] based on properties. + */ +fun LightDarkColors(isClassic: Boolean, isLight: Boolean, followSystemSettings: Boolean, primaryOrUnspecified: Color): LightDarkColors { + val primary = primaryOrUnspecified.takeOrElse { if (isClassic) primaryGreen else primaryBlue } + val light = when { + isLight || followSystemSettings -> if (isClassic) ClassicLight(primary) else OceanLight(primary) + else -> if (isClassic) ClassicDark(primary) else OceanDark(primary) + } + val dark = when { + isLight && !followSystemSettings -> if (isClassic) ClassicLight(primary) else OceanLight(primary) + else -> if (isClassic) ClassicDark(primary) else OceanDark(primary) + } + return LightDarkColors(light, dark) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/color/ColorsFromPreferences.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/color/ColorsFromPreferences.kt new file mode 100644 index 0000000000..a65c08cba0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/color/ColorsFromPreferences.kt @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.ui.color + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences.Companion.BLUE_ACCENT +import org.session.libsession.utilities.TextSecurePreferences.Companion.CLASSIC_DARK +import org.session.libsession.utilities.TextSecurePreferences.Companion.CLASSIC_LIGHT +import org.session.libsession.utilities.TextSecurePreferences.Companion.GREEN_ACCENT +import org.session.libsession.utilities.TextSecurePreferences.Companion.OCEAN_LIGHT +import org.session.libsession.utilities.TextSecurePreferences.Companion.ORANGE_ACCENT +import org.session.libsession.utilities.TextSecurePreferences.Companion.PINK_ACCENT +import org.session.libsession.utilities.TextSecurePreferences.Companion.PURPLE_ACCENT +import org.session.libsession.utilities.TextSecurePreferences.Companion.RED_ACCENT +import org.session.libsession.utilities.TextSecurePreferences.Companion.YELLOW_ACCENT + +/** + * Retrieve the current [Colors] from [TextSecurePreferences] and current system settings. + */ +@Composable +fun TextSecurePreferences.colors(): Colors = lightDarkColors().colors() +private fun TextSecurePreferences.lightDarkColors() = LightDarkColors(isClassic(), isLight(), getFollowSystemSettings(), primaryColor()) +private fun TextSecurePreferences.isLight(): Boolean = getThemeStyle() in setOf(CLASSIC_LIGHT, OCEAN_LIGHT) +private fun TextSecurePreferences.isClassic(): Boolean = getThemeStyle() in setOf(CLASSIC_DARK, CLASSIC_LIGHT) +private fun TextSecurePreferences.primaryColor(): Color = when(getSelectedAccentColor()) { + GREEN_ACCENT -> primaryGreen + BLUE_ACCENT -> primaryBlue + PURPLE_ACCENT -> primaryPurple + PINK_ACCENT -> primaryPink + RED_ACCENT -> primaryRed + ORANGE_ACCENT -> primaryOrange + YELLOW_ACCENT -> primaryYellow + else -> Color.Unspecified +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt new file mode 100644 index 0000000000..1069275056 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +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.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.color.Colors +import org.thoughtcrime.securesms.ui.h4 + +@Preview +@Composable +fun AppBarPreview( + @PreviewParameter(SessionColorsParameterProvider::class) colors: Colors +) { + PreviewTheme(colors) { + AppBar(title = "Title", {}, {}) + } +} + +@Composable +fun AppBar(title: String, onClose: () -> Unit = {}, onBack: (() -> Unit)? = null) { + Row(modifier = Modifier.height(LocalDimensions.current.appBarHeight), verticalAlignment = Alignment.CenterVertically) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(LocalDimensions.current.appBarHeight)) { + onBack?.let { + IconButton(onClick = it) { + Icon(painter = painterResource(id = R.drawable.ic_prev), contentDescription = "back") + } + } + } + Spacer(modifier = Modifier.weight(1f)) + Text(text = title, style = h4) + Spacer(modifier = Modifier.weight(1f)) + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(LocalDimensions.current.appBarHeight)) { + IconButton(onClick = onClose) { + Icon(painter = painterResource(id = R.drawable.ic_x), contentDescription = "close") + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Border.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Border.kt new file mode 100644 index 0000000000..4039f84ec6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Border.kt @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.compose.foundation.border +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import org.thoughtcrime.securesms.ui.LocalDimensions +import org.thoughtcrime.securesms.ui.color.LocalColors + +@Composable +fun Modifier.border() = this.border( + width = LocalDimensions.current.borderStroke, + brush = SolidColor(LocalColors.current.borders), + shape = MaterialTheme.shapes.small +) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt new file mode 100644 index 0000000000..23aee8c011 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt @@ -0,0 +1,320 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ButtonColors +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +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.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +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 kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filter +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.LaunchedEffectAsync +import org.thoughtcrime.securesms.ui.PreviewTheme +import org.thoughtcrime.securesms.ui.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.baseBold +import org.thoughtcrime.securesms.ui.buttonShape +import org.thoughtcrime.securesms.ui.color.Colors +import org.thoughtcrime.securesms.ui.color.LocalColors +import org.thoughtcrime.securesms.ui.contentDescription +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Base [Button] implementation + */ +@Composable +fun Button( + onClick: () -> Unit, + type: ButtonType, + modifier: Modifier = Modifier, + enabled: Boolean = true, + style: ButtonStyle = ButtonStyle.Large, + shape: Shape = buttonShape, + border: BorderStroke? = type.border(enabled), + colors: ButtonColors = type.buttonColors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + contentPadding: PaddingValues = type.contentPadding, + content: @Composable RowScope.() -> Unit +) { + style.applyButtonConstraints { + androidx.compose.material.Button( + onClick, + modifier.heightIn(min = style.minHeight), + enabled, + interactionSource, + elevation = null, + shape, + border, + colors, + contentPadding + ) { + // Button sets LocalTextStyle, so text style is applied inside to override that. + style.applyTextConstraints { + content() + } + } + } +} + +/** + * Courtesy [Button] implementation for buttons that just display text. + */ +@Composable +fun Button( + text: String, + onClick: () -> Unit, + type: ButtonType, + modifier: Modifier = Modifier, + enabled: Boolean = true, + style: ButtonStyle = ButtonStyle.Large, + shape: Shape = buttonShape, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + Button(onClick, type, modifier, enabled, style, shape, interactionSource = interactionSource) { + Text(text) + } +} + +@Composable fun FillButton(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { + Button(text, onClick, ButtonType.Fill, modifier, enabled) +} + +@Composable fun PrimaryFillButton(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { + Button(text, onClick, ButtonType.PrimaryFill, modifier, enabled) +} + +@Composable fun OutlineButton(text: String, modifier: Modifier = Modifier, color: Color = LocalColors.current.text, enabled: Boolean = true, onClick: () -> Unit) { + Button(text, onClick, ButtonType.Outline(color), modifier, enabled) +} + +@Composable fun OutlineButton(modifier: Modifier = Modifier, color: Color = LocalColors.current.text, enabled: Boolean = true, onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { + Button( + onClick = onClick, + type = ButtonType.Outline(color), + modifier = modifier, + enabled = enabled, + content = content + ) +} + +@Composable fun PrimaryOutlineButton(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { + Button(text, onClick, ButtonType.Outline(LocalColors.current.primaryButtonFill), modifier, enabled) +} + +@Composable fun PrimaryOutlineButton(onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, content: @Composable RowScope.() -> Unit) { + Button(onClick, ButtonType.Outline(LocalColors.current.primaryButtonFill), modifier, enabled, content = content) +} + +@Composable fun SlimOutlineButton(onClick: () -> Unit, modifier: Modifier = Modifier, color: Color = LocalColors.current.text, enabled: Boolean = true, content: @Composable RowScope.() -> Unit) { + Button(onClick, ButtonType.Outline(color), modifier, enabled, ButtonStyle.Slim, content = content) +} + +/** + * Courtesy [SlimOutlineButton] implementation for buttons that just display text. + */ +@Composable fun SlimOutlineButton(text: String, modifier: Modifier = Modifier, color: Color = LocalColors.current.text, enabled: Boolean = true, onClick: () -> Unit) { + Button(text, onClick, ButtonType.Outline(color), modifier, enabled, ButtonStyle.Slim) +} + +@Composable fun SlimPrimaryOutlineButton(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { + Button(text, onClick, ButtonType.Outline(LocalColors.current.primaryButtonFill), modifier, enabled, ButtonStyle.Slim) +} + +@Composable +fun PrimaryOutlineCopyButton( + modifier: Modifier = Modifier, + style: ButtonStyle = ButtonStyle.Large, + onClick: () -> Unit +) { + OutlineCopyButton(modifier, style, LocalColors.current.primaryButtonFill, onClick) +} + +@Composable +fun SlimOutlineCopyButton( + modifier: Modifier = Modifier, + color: Color = LocalColors.current.text, + onClick: () -> Unit +) { + OutlineCopyButton(modifier, ButtonStyle.Slim, color, onClick) +} + +@Composable +fun OutlineCopyButton( + modifier: Modifier = Modifier, + style: ButtonStyle = ButtonStyle.Large, + color: Color = LocalColors.current.text, + onClick: () -> Unit +) { + val interactionSource = remember { MutableInteractionSource() } + + Button( + modifier = modifier.contentDescription(R.string.AccessibilityId_copy_button), + interactionSource = interactionSource, + style = style, + type = ButtonType.Outline(color), + onClick = onClick + ) { + CopyButtonContent(interactionSource) + } +} + +@Composable +fun CopyButtonContent(interactionSource: MutableInteractionSource) { + TemporaryClickedContent( + interactionSource = interactionSource, + content = { Text(stringResource(R.string.copy)) }, + temporaryContent = { Text(stringResource(R.string.copied)) } + ) +} + +@Composable +fun TemporaryClickedContent( + interactionSource: MutableInteractionSource, + content: @Composable AnimatedVisibilityScope.() -> Unit, + temporaryContent: @Composable AnimatedVisibilityScope.() -> Unit, + temporaryDelay: Duration = 2.seconds +) { + var clicked by remember { mutableStateOf(false) } + + LaunchedEffectAsync { + interactionSource.releases.collectLatest { + clicked = true + delay(temporaryDelay) + clicked = false + } + } + + // Using a Box because the Buttons add children in a Row + // and they will jank as they are added and removed. + Box(contentAlignment = Alignment.Center) { + AnimatedVisibility(!clicked, enter = fadeIn(), exit = fadeOut(), content = content) + AnimatedVisibility(clicked, enter = fadeIn(), exit = fadeOut(), content = temporaryContent) + } +} + +/** + * Base [BorderlessButton] implementation. + */ +@Composable +fun BorderlessButton( + modifier: Modifier = Modifier, + color: Color = LocalColors.current.text, + onClick: () -> Unit, + content: @Composable RowScope.() -> Unit +) { + Button( + onClick = onClick, + modifier = modifier, + style = ButtonStyle.Borderless, + type = ButtonType.Borderless(color), + content = content + ) +} + +/** + * Courtesy [BorderlessButton] implementation that accepts [text] as a [String]. + */ +@Composable +fun BorderlessButton( + text: String, + modifier: Modifier = Modifier, + color: Color = LocalColors.current.text, + onClick: () -> Unit +) { + BorderlessButton(modifier, color, onClick) { Text(text) } +} + +@Composable +fun BorderlessButtonWithIcon( + text: String, + @DrawableRes iconRes: Int, + modifier: Modifier = Modifier, + style: TextStyle = baseBold, + color: Color = LocalColors.current.text, + onClick: () -> Unit +) { + BorderlessButton( + modifier = modifier, + color = color, + onClick = onClick + ) { + AnnotatedTextWithIcon(text, iconRes, style = style) + } +} + +@Composable +fun BorderlessHtmlButton( + textId: Int, + modifier: Modifier = Modifier, + color: Color = LocalColors.current.text, + onClick: () -> Unit +) { + BorderlessButton(modifier, color, onClick) { + Text( + text = annotatedStringResource(textId), + modifier = Modifier.padding(horizontal = 2.dp) + ) + } +} + +val MutableInteractionSource.releases + get() = interactions.filter { it is PressInteraction.Release } + +@OptIn(ExperimentalLayoutApi::class) +@Preview +@Composable +private fun VariousButtons( + @PreviewParameter(SessionColorsParameterProvider::class) colors: Colors +) { + PreviewTheme(colors) { + FlowRow( + modifier = Modifier.padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(8.dp), + maxItemsInEachRow = 2 + ) { + PrimaryFillButton("Primary Fill") {} + PrimaryFillButton("Primary Fill Disabled", enabled = false) {} + FillButton("Fill Button") {} + FillButton("Fill Button Disabled", enabled = false) {} + PrimaryOutlineButton("Primary Outline Button") {} + PrimaryOutlineButton("Primary Outline Disabled", enabled = false) {} + OutlineButton("Outline Button") {} + OutlineButton("Outline Button Disabled", enabled = false) {} + SlimOutlineButton("Slim Outline") {} + SlimOutlineButton("Slim Outline Disabled", enabled = false) {} + SlimPrimaryOutlineButton("Slim Primary") {} + SlimOutlineButton("Slim Danger", color = LocalColors.current.danger) {} + BorderlessButton("Borderless Button") {} + BorderlessButton("Borderless Secondary", color = LocalColors.current.textSecondary) {} + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonStyle.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonStyle.kt new file mode 100644 index 0000000000..3f566911db --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonStyle.kt @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms.ui.components + +import android.annotation.SuppressLint +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.LocalMinimumInteractiveComponentEnforcement +import androidx.compose.material.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.thoughtcrime.securesms.ui.baseBold +import org.thoughtcrime.securesms.ui.extraSmall +import org.thoughtcrime.securesms.ui.extraSmallBold + +interface ButtonStyle { + @OptIn(ExperimentalMaterialApi::class) + @SuppressLint("ComposableNaming") + @Composable fun applyButtonConstraints(content: @Composable () -> Unit) { + CompositionLocalProvider( + LocalMinimumInteractiveComponentEnforcement provides false, + content = content + ) + } + + @SuppressLint("ComposableNaming") + @Composable fun applyTextConstraints(content: @Composable () -> Unit) { + CompositionLocalProvider( + LocalTextStyle provides textStyle, + content = content + ) + } + + val textStyle: TextStyle + val minHeight: Dp + + object Large: ButtonStyle { + override val textStyle = baseBold.copy(textAlign = TextAlign.Center) + override val minHeight = 41.dp + } + + object Slim: ButtonStyle { + override val textStyle = extraSmallBold.copy(textAlign = TextAlign.Center) + override val minHeight = 29.dp + } + + object Borderless: ButtonStyle { + override val textStyle = extraSmall.copy(textAlign = TextAlign.Center) + override val minHeight = 37.dp + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt new file mode 100644 index 0000000000..e941ab4e83 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt @@ -0,0 +1,80 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material.ButtonColors +import androidx.compose.material.ButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.thoughtcrime.securesms.ui.LocalDimensions +import org.thoughtcrime.securesms.ui.color.LocalColors + +private val disabledBorder @Composable get() = BorderStroke( + width = LocalDimensions.current.borderStroke, + color = LocalColors.current.disabled +) + +interface ButtonType { + val contentPadding: PaddingValues get() = ButtonDefaults.ContentPadding + + @Composable + fun border(enabled: Boolean): BorderStroke? + @Composable + fun buttonColors(): ButtonColors + + class Outline( + private val contentColor: Color, + private val borderColor: Color = contentColor + ): ButtonType { + @Composable + override fun border(enabled: Boolean) = BorderStroke( + width = LocalDimensions.current.borderStroke, + color = if (enabled) borderColor else LocalColors.current.disabled + ) + @Composable + override fun buttonColors() = ButtonDefaults.buttonColors( + contentColor = contentColor, + backgroundColor = Color.Unspecified, + disabledContentColor = LocalColors.current.disabled, + disabledBackgroundColor = Color.Unspecified + ) + } + + object Fill: ButtonType { + @Composable + override fun border(enabled: Boolean) = if (enabled) null else disabledBorder + @Composable + override fun buttonColors() = ButtonDefaults.buttonColors( + contentColor = LocalColors.current.background, + backgroundColor = LocalColors.current.text, + disabledContentColor = LocalColors.current.disabled, + disabledBackgroundColor = Color.Unspecified + ) + } + + object PrimaryFill: ButtonType { + @Composable + override fun border(enabled: Boolean) = if (enabled) null else disabledBorder + @Composable + override fun buttonColors() = ButtonDefaults.buttonColors( + contentColor = LocalColors.current.primaryButtonFillText, + backgroundColor = LocalColors.current.primaryButtonFill, + disabledContentColor = LocalColors.current.disabled, + disabledBackgroundColor = Color.Unspecified + ) + } + + class Borderless(private val color: Color): ButtonType { + override val contentPadding: PaddingValues + get() = PaddingValues(horizontal = 16.dp, vertical = 12.dp) + @Composable + override fun border(enabled: Boolean) = null + @Composable + override fun buttonColors() = ButtonDefaults.outlinedButtonColors( + contentColor = color, + backgroundColor = Color.Transparent, + disabledContentColor = LocalColors.current.disabled + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt new file mode 100644 index 0000000000..259bbfe170 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.compose.foundation.layout.size +import androidx.compose.material.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun CircularProgressIndicator(color: Color = LocalContentColor.current) { + androidx.compose.material.CircularProgressIndicator( + modifier = Modifier.size(40.dp), + color = color, + strokeWidth = 2.dp + ) +} + +@Composable +fun SmallCircularProgressIndicator(color: Color = LocalContentColor.current) { + androidx.compose.material.CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = color, + strokeWidth = 2.dp + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Html.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Html.kt new file mode 100644 index 0000000000..951db1816e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Html.kt @@ -0,0 +1,180 @@ +package org.thoughtcrime.securesms.ui.components + +import android.content.res.Resources +import android.graphics.Typeface +import android.text.Spanned +import android.text.SpannedString +import android.text.style.* +import android.util.Log +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.core.text.HtmlCompat + +// TODO Remove this file once we update to composeVersion=1.7.0-alpha06 fixes https://issuetracker.google.com/issues/139320238?pli=1 +// which allows Stylized string in string resources +@Composable +@ReadOnlyComposable +private fun resources(): Resources { + LocalConfiguration.current + return LocalContext.current.resources +} + +fun Spanned.toHtmlWithoutParagraphs(): String { + return HtmlCompat.toHtml(this, HtmlCompat.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE) + .substringAfter("

").substringBeforeLast("

") +} + +fun Resources.getText(@StringRes id: Int, vararg args: Any): CharSequence { + val escapedArgs = args.map { + if (it is Spanned) it.toHtmlWithoutParagraphs() else it + }.toTypedArray() + val resource = SpannedString(getText(id)) + val htmlResource = resource.toHtmlWithoutParagraphs() + val formattedHtml = String.format(htmlResource, *escapedArgs) + return HtmlCompat.fromHtml(formattedHtml, HtmlCompat.FROM_HTML_MODE_LEGACY) +} + +@Composable +fun annotatedStringResource(@StringRes id: Int, vararg formatArgs: Any): AnnotatedString { + val resources = resources() + val density = LocalDensity.current + return remember(id, formatArgs) { + val text = resources.getText(id, *formatArgs) + spannableStringToAnnotatedString(text, density) + } +} + +@Composable +fun annotatedStringResource(@StringRes id: Int): AnnotatedString { + val resources = resources() + val density = LocalDensity.current + return remember(id) { + val text = resources.getText(id) + spannableStringToAnnotatedString(text, density) + } +} + +private fun spannableStringToAnnotatedString( + text: CharSequence, + density: Density +): AnnotatedString { + return if (text is Spanned) { + with(density) { + buildAnnotatedString { + append((text.toString())) + text.getSpans(0, text.length, Any::class.java).forEach { + val start = text.getSpanStart(it) + val end = text.getSpanEnd(it) + when (it) { + is StyleSpan -> when (it.style) { + Typeface.NORMAL -> addStyle( + SpanStyle( + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Normal + ), + start, + end + ) + Typeface.BOLD -> addStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Normal + ), + start, + end + ) + Typeface.ITALIC -> addStyle( + SpanStyle( + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Italic + ), + start, + end + ) + Typeface.BOLD_ITALIC -> addStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Italic + ), + start, + end + ) + } + is TypefaceSpan -> addStyle( + SpanStyle( + fontFamily = when (it.family) { + FontFamily.SansSerif.name -> FontFamily.SansSerif + FontFamily.Serif.name -> FontFamily.Serif + FontFamily.Monospace.name -> FontFamily.Monospace + FontFamily.Cursive.name -> FontFamily.Cursive + else -> FontFamily.Default + } + ), + start, + end + ) + is BulletSpan -> { + Log.d("StringResources", "BulletSpan not supported yet") + addStyle(SpanStyle(), start, end) + } + is AbsoluteSizeSpan -> addStyle( + SpanStyle(fontSize = if (it.dip) it.size.dp.toSp() else it.size.toSp()), + start, + end + ) + is RelativeSizeSpan -> addStyle( + SpanStyle(fontSize = it.sizeChange.em), + start, + end + ) + is StrikethroughSpan -> addStyle( + SpanStyle(textDecoration = TextDecoration.LineThrough), + start, + end + ) + is UnderlineSpan -> addStyle( + SpanStyle(textDecoration = TextDecoration.Underline), + start, + end + ) + is SuperscriptSpan -> addStyle( + SpanStyle(baselineShift = BaselineShift.Superscript), + start, + end + ) + is SubscriptSpan -> addStyle( + SpanStyle(baselineShift = BaselineShift.Subscript), + start, + end + ) + is ForegroundColorSpan -> addStyle( + SpanStyle(color = Color(it.foregroundColor)), + start, + end + ) + else -> addStyle(SpanStyle(), start, end) + } + } + } + } + } else { + AnnotatedString(text.toString()) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt new file mode 100644 index 0000000000..2d95a13d5c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt @@ -0,0 +1,246 @@ +package org.thoughtcrime.securesms.ui.components + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Scaffold +import androidx.compose.material.Snackbar +import androidx.compose.material.SnackbarHost +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.ui.LocalDimensions +import org.thoughtcrime.securesms.ui.base +import org.thoughtcrime.securesms.ui.color.LocalColors +import org.thoughtcrime.securesms.ui.xl +import java.util.concurrent.Executors + +private const val TAG = "NewMessageFragment" + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun MaybeScanQrCode( + errors: Flow, + onClickSettings: () -> Unit = LocalContext.current.run { { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + }.let(::startActivity) + } }, + onScan: (String) -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(LocalColors.current.background) + ) { + LocalSoftwareKeyboardController.current?.hide() + + val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) + + if (cameraPermissionState.status.isGranted) { + ScanQrCode(errors, onScan) + } else if (cameraPermissionState.status.shouldShowRationale) { + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 60.dp) + ) { + Text( + stringResource(R.string.activity_link_camera_permission_permanently_denied_configure_in_settings), + style = base, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.size(LocalDimensions.current.itemSpacing)) + OutlineButton( + stringResource(R.string.sessionSettings), + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = onClickSettings + ) + } + } else { + Column( + modifier = Modifier + .background(color = LocalColors.current.backgroundSecondary) + .fillMaxSize() + .padding(LocalDimensions.current.largeMargin), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(1f)) + Text(stringResource(R.string.fragment_scan_qr_code_camera_access_explanation), style = xl, textAlign = TextAlign.Center) + Spacer(modifier = Modifier.height(LocalDimensions.current.itemSpacing)) + PrimaryOutlineButton( + stringResource(R.string.cameraGrantAccess), + modifier = Modifier.fillMaxWidth(), + onClick = { cameraPermissionState.run { launchPermissionRequest() } } + ) + Spacer(modifier = Modifier.weight(1f)) + } + } + } +} + +@Composable +fun ScanQrCode(errors: Flow, onScan: (String) -> Unit) { + val localContext = LocalContext.current + val cameraProvider = remember { ProcessCameraProvider.getInstance(localContext) } + + val preview = Preview.Builder().build() + val selector = CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_BACK) + .build() + + runCatching { + cameraProvider.get().unbindAll() + + val options = BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + val scanner = BarcodeScanning.getClient(options) + + cameraProvider.get().bindToLifecycle( + LocalLifecycleOwner.current, + selector, + preview, + buildAnalysisUseCase(scanner, onScan) + ) + }.onFailure { Log.e(TAG, "error binding camera", it) } + + DisposableEffect(cameraProvider) { + onDispose { + cameraProvider.get().unbindAll() + } + } + + val scaffoldState = rememberScaffoldState() + + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + errors.collect { error -> + scaffoldState.snackbarHostState + .takeIf { it.currentSnackbarData == null } + ?.run { + scope.launch { + // showSnackbar() suspends until the Snackbar is dismissed. + // Launch in new scope so we drop new QR scan events, to prevent spamming + // snackbars to the user, or worse, queuing a chain of snackbars one after + // another to show and hide for the next minute or 2. + // Don't use debounce() because many QR scans can come through each second, + // and each scan could restart the timer which could mean no scan gets + // through until the user stops scanning; quite perplexing. + showSnackbar(message = error) + } + } + } + } + + Scaffold( + scaffoldState = scaffoldState, + snackbarHost = { + SnackbarHost( + hostState = scaffoldState.snackbarHostState, + modifier = Modifier.padding(LocalDimensions.current.smallItemSpacing) + ) { data -> + Snackbar( + snackbarData = data, + modifier = Modifier.padding(LocalDimensions.current.smallItemSpacing) + ) + } + } + ) { padding -> + Box(modifier = Modifier.padding(padding)) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { PreviewView(it).apply { preview.setSurfaceProvider(surfaceProvider) } } + ) + + Box( + Modifier + .aspectRatio(1f) + .padding(LocalDimensions.current.itemSpacing) + .clip(shape = RoundedCornerShape(26.dp)) + .background(Color(0x33ffffff)) + .align(Alignment.Center) + ) + } + } +} + +@SuppressLint("UnsafeOptInUsageError") +private fun buildAnalysisUseCase( + scanner: BarcodeScanner, + onBarcodeScanned: (String) -> Unit +): ImageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build().apply { + setAnalyzer(Executors.newSingleThreadExecutor(), Analyzer(scanner, onBarcodeScanned)) + } + +class Analyzer( + private val scanner: BarcodeScanner, + private val onBarcodeScanned: (String) -> Unit +): ImageAnalysis.Analyzer { + @SuppressLint("UnsafeOptInUsageError") + override fun analyze(image: ImageProxy) { + InputImage.fromMediaImage( + image.image!!, + image.imageInfo.rotationDegrees + ).let(scanner::process).apply { + addOnSuccessListener { barcodes -> + barcodes.forEach { + it.rawValue?.let(onBarcodeScanned) + } + } + addOnCompleteListener { + image.close() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QrImage.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QrImage.kt new file mode 100644 index 0000000000..48d81aa723 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QrImage.kt @@ -0,0 +1,109 @@ +package org.thoughtcrime.securesms.ui.components + +import android.graphics.Bitmap +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Card +import androidx.compose.material.Icon +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.LocalDimensions +import org.thoughtcrime.securesms.ui.color.LocalColors +import org.thoughtcrime.securesms.util.QRCodeUtilities + +@Composable +fun QrImage( + string: String?, + modifier: Modifier = Modifier, + contentPadding: Dp = LocalDimensions.current.smallItemSpacing, + icon: Int = R.drawable.session_shield +) { + var bitmap: Bitmap? by remember { + mutableStateOf(null) + } + + val scope = rememberCoroutineScope() + LaunchedEffect(string) { + if (string != null) scope.launch(Dispatchers.IO) { + bitmap = (300..500 step 100).firstNotNullOf { + runCatching { QRCodeUtilities.encode(string, it) }.getOrNull() + } + } + } + + Card( + backgroundColor = LocalColors.current.qrCodeBackground, + elevation = 0.dp, + modifier = modifier + ) { Content(bitmap, icon, Modifier.padding(contentPadding), backgroundColor = LocalColors.current.qrCodeBackground) } +} + +@Composable +private fun Content( + bitmap: Bitmap?, + icon: Int, + modifier: Modifier = Modifier, + qrColor: Color = LocalColors.current.qrCodeContent, + backgroundColor: Color, +) { + Box( + modifier = modifier + .fillMaxWidth() + .aspectRatio(1f) + ) { + AnimatedVisibility( + visible = bitmap != null, + enter = fadeIn(), + ) { + bitmap?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "", + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + colorFilter = ColorFilter.tint(qrColor), + // Use FilterQuality.None to keep QR edges sharp + filterQuality = FilterQuality.None + ) + } + } + + Icon( + painter = painterResource(id = icon), + contentDescription = "", + tint = qrColor, + modifier = Modifier + .size(62.dp) + .align(Alignment.Center) + .background(color = backgroundColor) + .size(66.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt new file mode 100644 index 0000000000..8058459a7c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt @@ -0,0 +1,99 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.TextButton +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import org.thoughtcrime.securesms.ui.LocalDimensions +import org.thoughtcrime.securesms.ui.color.LocalColors +import org.thoughtcrime.securesms.ui.color.transparentButtonColors + +@Composable +fun RadioButton( + onClick: () -> Unit = {}, + modifier: Modifier = Modifier, + checked: Boolean = false, + contentPadding: PaddingValues = PaddingValues(), + content: @Composable RowScope.() -> Unit = {} +) { + TextButton( + modifier = modifier + .fillMaxWidth() + .selectable( + selected = checked, + enabled = true, + role = Role.RadioButton, + onClick = onClick + ), + colors = transparentButtonColors(), + onClick = onClick, + shape = RectangleShape, + contentPadding = contentPadding + ) { + content() + Spacer(modifier = Modifier.width(20.dp)) + RadioButtonIndicator( + checked = checked, + modifier = Modifier + .size(22.dp) + .align(Alignment.CenterVertically) + ) + } +} + +@Composable +private fun RadioButtonIndicator( + checked: Boolean, + modifier: Modifier +) { + Box(modifier = modifier) { + AnimatedVisibility( + checked, + modifier = Modifier + .padding(2.5.dp) + .clip(CircleShape), + enter = scaleIn(), + exit = scaleOut() + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + color = LocalColors.current.primary, + shape = CircleShape + ) + ) + } + Box( + modifier = Modifier + .aspectRatio(1f) + .border( + width = LocalDimensions.current.borderStroke, + color = LocalColors.current.text, + shape = CircleShape + ) + ) {} + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionTabRow.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionTabRow.kt new file mode 100644 index 0000000000..5a3956922e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionTabRow.kt @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.Tab +import androidx.compose.material.TabRow +import androidx.compose.material.TabRowDefaults +import androidx.compose.material.TabRowDefaults.tabIndicatorOffset +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.LocalDimensions +import org.thoughtcrime.securesms.ui.color.LocalColors +import org.thoughtcrime.securesms.ui.PreviewTheme +import org.thoughtcrime.securesms.ui.color.Colors +import org.thoughtcrime.securesms.ui.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.color.divider +import org.thoughtcrime.securesms.ui.h8 + +private val TITLES = listOf(R.string.sessionRecoveryPassword, R.string.qrScan) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SessionTabRow(pagerState: PagerState, titles: List) { + TabRow( + backgroundColor = Color.Unspecified, + selectedTabIndex = pagerState.currentPage, + contentColor = LocalColors.current.text, + indicator = { tabPositions -> + TabRowDefaults.Indicator( + Modifier.tabIndicatorOffset(tabPositions[pagerState.currentPage]), + color = LocalColors.current.primary, + height = LocalDimensions.current.indicatorHeight + ) + }, + divider = { TabRowDefaults.Divider(color = LocalColors.current.divider) }, + modifier = Modifier + .height(48.dp) + .background(color = Color.Unspecified) + ) { + val animationScope = rememberCoroutineScope() + titles.forEachIndexed { i, it -> + Tab( + i == pagerState.currentPage, + onClick = { animationScope.launch { pagerState.animateScrollToPage(i) } }, + selectedContentColor = LocalColors.current.text, + unselectedContentColor = LocalColors.current.text, + ) { + Text( + stringResource(id = it), + style = h8 + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@androidx.compose.ui.tooling.preview.Preview +@Composable +fun PreviewSessionTabRow( + @PreviewParameter(SessionColorsParameterProvider::class) colors: Colors +) { + PreviewTheme(colors) { + val pagerState = rememberPagerState { TITLES.size } + SessionTabRow(pagerState = pagerState, titles = TITLES) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt new file mode 100644 index 0000000000..b6a12a792d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt @@ -0,0 +1,189 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.annotation.DrawableRes +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.border +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.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +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.graphics.SolidColor +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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.baseBold +import org.thoughtcrime.securesms.ui.color.LocalColors +import org.thoughtcrime.securesms.ui.color.borders +import org.thoughtcrime.securesms.ui.color.text +import org.thoughtcrime.securesms.ui.color.textSecondary +import org.thoughtcrime.securesms.ui.contentDescription + +@Preview +@Composable +fun PreviewSessionOutlinedTextField() { + PreviewTheme { + Column(modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp)) { + SessionOutlinedTextField( + text = "text", + placeholder = "", + ) + + SessionOutlinedTextField( + text = "", + placeholder = "placeholder" + ) + + SessionOutlinedTextField( + text = "text", + placeholder = "", + error = "error" + ) + + SessionOutlinedTextField( + text = "text onChange after error", + placeholder = "", + error = "error", + isTextErrorColor = false + ) + } + } +} + +@Composable +fun SessionOutlinedTextField( + text: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + onChange: (String) -> Unit = {}, + textStyle: TextStyle = base, + placeholder: String = "", + onContinue: () -> Unit = {}, + error: String? = null, + isTextErrorColor: Boolean = error != null +) { + Column(modifier = modifier.animateContentSize()) { + Box( + modifier = Modifier.border( + width = LocalDimensions.current.borderStroke, + color = LocalColors.current.borders(error != null), + shape = MaterialTheme.shapes.small + ) + .fillMaxWidth() + .wrapContentHeight() + .padding(vertical = 28.dp) + .padding(horizontal = 21.dp) + ) { + if (text.isEmpty()) { + Text( + text = placeholder, + style = base, + color = LocalColors.current.textSecondary(isTextErrorColor), + modifier = Modifier.wrapContentSize() + .align(Alignment.CenterStart) + .wrapContentSize() + ) + } + + BasicTextField( + value = text, + onValueChange = onChange, + modifier = Modifier.wrapContentHeight().fillMaxWidth().contentDescription(contentDescription), + textStyle = textStyle.copy(color = LocalColors.current.text(isTextErrorColor)), + cursorBrush = SolidColor(LocalColors.current.text(isTextErrorColor)), + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { onContinue() }, + onGo = { onContinue() }, + onSearch = { onContinue() }, + onSend = { onContinue() }, + ) + ) + } + error?.let { + Spacer(modifier = Modifier.height(LocalDimensions.current.xsItemSpacing)) + Text( + it, + modifier = Modifier.fillMaxWidth() + .contentDescription(R.string.AccessibilityId_error_message), + textAlign = TextAlign.Center, + style = baseBold, + color = LocalColors.current.danger + ) + } + } +} + +@Composable +fun AnnotatedTextWithIcon( + text: String, + @DrawableRes iconRes: Int, + modifier: Modifier = Modifier, + style: TextStyle = base, + color: Color = Color.Unspecified, + iconSize: TextUnit = 12.sp +) { + val myId = "inlineContent" + val annotated = buildAnnotatedString { + append(text) + appendInlineContent(myId, "[icon]") + } + + val inlineContent = mapOf( + Pair( + myId, + InlineTextContent( + Placeholder( + width = iconSize, + height = iconSize, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter + ) + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + modifier = Modifier.padding(1.dp), + tint = color + ) + } + ) + ) + + Text( + text = annotated, + modifier = modifier.fillMaxWidth(), + style = style, + color = color, + textAlign = TextAlign.Center, + inlineContent = inlineContent + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ActivityUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ActivityUtilities.kt index 5ff823a15c..c3b7eaca96 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ActivityUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ActivityUtilities.kt @@ -1,8 +1,10 @@ package org.thoughtcrime.securesms.util import android.annotation.SuppressLint +import android.app.Activity import android.content.Context import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP import android.view.View import androidx.annotation.StyleRes import androidx.appcompat.app.AppCompatActivity @@ -10,6 +12,10 @@ import androidx.appcompat.widget.Toolbar import androidx.fragment.app.DialogFragment import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences.Companion.CLASSIC_DARK +import org.session.libsession.utilities.TextSecurePreferences.Companion.CLASSIC_LIGHT +import org.session.libsession.utilities.TextSecurePreferences.Companion.OCEAN_DARK +import org.session.libsession.utilities.TextSecurePreferences.Companion.OCEAN_LIGHT import org.thoughtcrime.securesms.BaseActionBarActivity fun BaseActionBarActivity.setUpActionBarSessionLogo(hideBackButton: Boolean = false) { @@ -82,20 +88,25 @@ fun TextSecurePreferences.themeState(): ThemeState { @StyleRes fun String.getThemeStyle(): Int = when (this) { - TextSecurePreferences.CLASSIC_DARK -> R.style.Classic_Dark - TextSecurePreferences.CLASSIC_LIGHT -> R.style.Classic_Light - TextSecurePreferences.OCEAN_DARK -> R.style.Ocean_Dark - TextSecurePreferences.OCEAN_LIGHT -> R.style.Ocean_Light + CLASSIC_DARK -> R.style.Classic_Dark + CLASSIC_LIGHT -> R.style.Classic_Light + OCEAN_DARK -> R.style.Ocean_Dark + OCEAN_LIGHT -> R.style.Ocean_Light else -> throw NullPointerException("The style [$this] is not supported") } @StyleRes -fun Int.getDefaultAccentColor(): Int = - if (this == R.style.Ocean_Dark || this == R.style.Ocean_Light) R.style.PrimaryBlue - else R.style.PrimaryGreen +fun Int.getDefaultAccentColor(): Int = when (this) { + R.style.Ocean_Dark, R.style.Ocean_Light -> R.style.PrimaryBlue + else -> R.style.PrimaryGreen +} data class ThemeState ( @StyleRes val theme: Int, @StyleRes val accentStyle: Int, val followSystem: Boolean -) \ No newline at end of file +) + +inline fun Activity.show() = Intent(this, T::class.java).also(::startActivity).let { overridePendingTransition(R.anim.slide_from_bottom, R.anim.fade_scale_out) } +inline fun Activity.push(modify: Intent.() -> Unit = {}) = Intent(this, T::class.java).also(modify).also(::startActivity).let { overridePendingTransition(R.anim.slide_from_right, R.anim.fade_scale_out) } +inline fun Context.start(modify: Intent.() -> Unit = {}) = Intent(this, T::class.java).also(modify).apply { addFlags(FLAG_ACTIVITY_SINGLE_TOP) }.let(::startActivity) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt index a467614376..e59d3aae17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt @@ -95,10 +95,10 @@ object ConfigurationMessageUtilities { val storage = MessagingModuleConfiguration.shared.storage val localUserKey = storage.getUserPublicKey() ?: return null val contactsWithSettings = storage.getAllContacts().filter { recipient -> - recipient.sessionID != localUserKey && recipient.sessionID.startsWith(IdPrefix.STANDARD.value) - && storage.getThreadId(recipient.sessionID) != null + recipient.accountID != localUserKey && recipient.accountID.startsWith(IdPrefix.STANDARD.value) + && storage.getThreadId(recipient.accountID) != null }.map { contact -> - val address = Address.fromSerialized(contact.sessionID) + val address = Address.fromSerialized(contact.accountID) val thread = storage.getThreadId(address) val isPinned = if (thread != null) { storage.isPinned(thread) @@ -117,7 +117,7 @@ object ConfigurationMessageUtilities { } val contactInfo = Contact( - id = contact.sessionID, + id = contact.accountID, name = contact.name.orEmpty(), nickname = contact.nickname.orEmpty(), blocked = settings.isBlocked, @@ -205,7 +205,7 @@ object ConfigurationMessageUtilities { val admins = group.admins.map { it.serialize() to true }.toMap() val members = group.members.filterNot { it.serialize() !in admins.keys }.map { it.serialize() to false }.toMap() GroupInfo.LegacyGroupInfo( - sessionId = groupPublicKey, + accountId = groupPublicKey, name = group.title, members = admins + members, priority = if (isPinned) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt b/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt index c7d53c1fef..a0c0da24fe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt @@ -118,7 +118,7 @@ class PNModeView : LinearLayout, GlowView { // endregion } -class NewConversationButtonImageView : androidx.appcompat.widget.AppCompatImageView, GlowView { +class StartConversationButtonImageView : androidx.appcompat.widget.AppCompatImageView, GlowView { @ColorInt override var mainColor: Int = 0 set(newValue) { field = newValue; paint.color = newValue } @ColorInt override var sessionShadowColor: Int = 0 diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt b/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt index 4d00da8f96..a7eb864893 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt @@ -55,7 +55,7 @@ object MockDataGenerator { val stringContent: List = "abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789 ".map { it.toString() } val wordContent: List = listOf("alias", "consequatur", "aut", "perferendis", "sit", "voluptatem", "accusantium", "doloremque", "aperiam", "eaque", "ipsa", "quae", "ab", "illo", "inventore", "veritatis", "et", "quasi", "architecto", "beatae", "vitae", "dicta", "sunt", "explicabo", "aspernatur", "aut", "odit", "aut", "fugit", "sed", "quia", "consequuntur", "magni", "dolores", "eos", "qui", "ratione", "voluptatem", "sequi", "nesciunt", "neque", "dolorem", "ipsum", "quia", "dolor", "sit", "amet", "consectetur", "adipisci", "velit", "sed", "quia", "non", "numquam", "eius", "modi", "tempora", "incidunt", "ut", "labore", "et", "dolore", "magnam", "aliquam", "quaerat", "voluptatem", "ut", "enim", "ad", "minima", "veniam", "quis", "nostrum", "exercitationem", "ullam", "corporis", "nemo", "enim", "ipsam", "voluptatem", "quia", "voluptas", "sit", "suscipit", "laboriosam", "nisi", "ut", "aliquid", "ex", "ea", "commodi", "consequatur", "quis", "autem", "vel", "eum", "iure", "reprehenderit", "qui", "in", "ea", "voluptate", "velit", "esse", "quam", "nihil", "molestiae", "et", "iusto", "odio", "dignissimos", "ducimus", "qui", "blanditiis", "praesentium", "laudantium", "totam", "rem", "voluptatum", "deleniti", "atque", "corrupti", "quos", "dolores", "et", "quas", "molestias", "excepturi", "sint", "occaecati", "cupiditate", "non", "provident", "sed", "ut", "perspiciatis", "unde", "omnis", "iste", "natus", "error", "similique", "sunt", "in", "culpa", "qui", "officia", "deserunt", "mollitia", "animi", "id", "est", "laborum", "et", "dolorum", "fuga", "et", "harum", "quidem", "rerum", "facilis", "est", "et", "expedita", "distinctio", "nam", "libero", "tempore", "cum", "soluta", "nobis", "est", "eligendi", "optio", "cumque", "nihil", "impedit", "quo", "porro", "quisquam", "est", "qui", "minus", "id", "quod", "maxime", "placeat", "facere", "possimus", "omnis", "voluptas", "assumenda", "est", "omnis", "dolor", "repellendus", "temporibus", "autem", "quibusdam", "et", "aut", "consequatur", "vel", "illum", "qui", "dolorem", "eum", "fugiat", "quo", "voluptas", "nulla", "pariatur", "at", "vero", "eos", "et", "accusamus", "officiis", "debitis", "aut", "rerum", "necessitatibus", "saepe", "eveniet", "ut", "et", "voluptates", "repudiandae", "sint", "et", "molestiae", "non", "recusandae", "itaque", "earum", "rerum", "hic", "tenetur", "a", "sapiente", "delectus", "ut", "aut", "reiciendis", "voluptatibus", "maiores", "doloribus", "asperiores", "repellat") val timestampNow: Long = System.currentTimeMillis() - val userSessionId: String = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!! + val userAccountId: String = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!! val logProgress: ((String, String) -> Unit) = logProgress@{ title, event -> if (!printProgress) { return@logProgress } @@ -84,7 +84,7 @@ object MockDataGenerator { logProgress("DM Thread $threadIndex", "Start") val dataBytes = (0 until 16).map { dmThreadRandomGenerator.nextInt(UByte.MAX_VALUE.toInt()).toByte() } - val randomSessionId: String = KeyPairUtilities.generate(dataBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey + val randomAccountId: String = KeyPairUtilities.generate(dataBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey val isMessageRequest: Boolean = dmThreadRandomGenerator.nextBoolean() val contactNameLength: Int = (5 + dmThreadRandomGenerator.nextInt(15)) @@ -94,8 +94,8 @@ object MockDataGenerator { ) // Generate the thread - val recipient = Recipient.from(context, Address.fromSerialized(randomSessionId), false) - val contact = Contact(randomSessionId) + val recipient = Recipient.from(context, Address.fromSerialized(randomAccountId), false) + val contact = Contact(randomAccountId) val threadId = threadDb.getOrCreateThreadIdFor(recipient) // Generate the contact @@ -194,16 +194,16 @@ object MockDataGenerator { ) // Generate the Contacts in the group - val members: MutableList = mutableListOf(userSessionId) + val members: MutableList = mutableListOf(userAccountId) logProgress("Closed Group Thread $threadIndex", "Generate $numGroupMembers Contacts") (0 until numGroupMembers).forEach { val contactBytes = (0 until 16).map { cgThreadRandomGenerator.nextInt(UByte.MAX_VALUE.toInt()).toByte() } - val randomSessionId: String = KeyPairUtilities.generate(contactBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey + val randomAccountId: String = KeyPairUtilities.generate(contactBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey val contactNameLength: Int = (5 + cgThreadRandomGenerator.nextInt(15)) - val recipient = Recipient.from(context, Address.fromSerialized(randomSessionId), false) - val contact = Contact(randomSessionId) + val recipient = Recipient.from(context, Address.fromSerialized(randomAccountId), false) + val contact = Contact(randomAccountId) contactDb.setContact(contact) recipientDb.setApproved(recipient, true) recipientDb.setApprovedMe(recipient, true) @@ -213,7 +213,7 @@ object MockDataGenerator { .joinToString() recipientDb.setProfileName(recipient, contact.name) contactDb.setContact(contact) - members.add(randomSessionId) + members.add(randomAccountId) } val groupId = GroupUtil.doubleEncodeGroupID(randomGroupPublicKey) @@ -237,7 +237,7 @@ object MockDataGenerator { storage.createInitialConfigGroup(randomGroupPublicKey, groupName, GroupUtil.createConfigMemberMap(members, setOf(adminUserId)), System.currentTimeMillis(), encryptionKeyPair, 0) // Add the group created message - if (userSessionId == adminUserId) { + if (userAccountId == adminUserId) { storage.insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, groupName, members, listOf(adminUserId), threadId, (timestampNow - (numMessages * 5000))) } else { storage.insertIncomingInfoMessage(context, adminUserId, groupId, SignalServiceGroup.Type.CREATION, groupName, members, listOf(adminUserId), (timestampNow - (numMessages * 5000))) @@ -250,7 +250,7 @@ object MockDataGenerator { val messageWords: Int = (1 + cgThreadRandomGenerator.nextInt(19)) val senderId: String = members.random(cgThreadRandomGenerator.asKotlinRandom()) - if (senderId != userSessionId) { + if (senderId != userAccountId) { smsDb.insertMessageInbox( IncomingTextMessage( Address.fromSerialized(senderId), @@ -331,16 +331,16 @@ object MockDataGenerator { ) // Generate the Contacts in the group - val members: MutableList = mutableListOf(userSessionId) + val members: MutableList = mutableListOf(userAccountId) logProgress("Open Group Thread $threadIndex", "Generate $numGroupMembers Contacts") (0 until numGroupMembers).forEach { val contactBytes = (0 until 16).map { ogThreadRandomGenerator.nextInt(UByte.MAX_VALUE.toInt()).toByte() } - val randomSessionId: String = KeyPairUtilities.generate(contactBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey + val randomAccountId: String = KeyPairUtilities.generate(contactBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey val contactNameLength: Int = (5 + ogThreadRandomGenerator.nextInt(15)) - val recipient = Recipient.from(context, Address.fromSerialized(randomSessionId), false) - val contact = Contact(randomSessionId) + val recipient = Recipient.from(context, Address.fromSerialized(randomAccountId), false) + val contact = Contact(randomAccountId) contactDb.setContact(contact) recipientDb.setApproved(recipient, true) recipientDb.setApprovedMe(recipient, true) @@ -350,7 +350,7 @@ object MockDataGenerator { .joinToString() recipientDb.setProfileName(recipient, contact.name) contactDb.setContact(contact) - members.add(randomSessionId) + members.add(randomAccountId) } // Create the open group model and the thread @@ -377,7 +377,7 @@ object MockDataGenerator { val messageWords: Int = (1 + ogThreadRandomGenerator.nextInt(19)) val senderId: String = members.random(ogThreadRandomGenerator.asKotlinRandom()) - if (senderId != userSessionId) { + if (senderId != userAccountId) { smsDb.insertMessageInbox( IncomingTextMessage( Address.fromSerialized(senderId), diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/QRCodeUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/QRCodeUtilities.kt index f7d1e3e8ad..80eccae41a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/QRCodeUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/QRCodeUtilities.kt @@ -4,28 +4,35 @@ import android.graphics.Bitmap import android.graphics.Color import com.google.zxing.BarcodeFormat import com.google.zxing.EncodeHintType -import com.google.zxing.WriterException import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel object QRCodeUtilities { - fun encode(data: String, size: Int, isInverted: Boolean = false, hasTransparentBackground: Boolean = true): Bitmap { - try { - val hints = hashMapOf( EncodeHintType.MARGIN to 1 ) - val result = QRCodeWriter().encode(data, BarcodeFormat.QR_CODE, size, size, hints) - val bitmap = Bitmap.createBitmap(result.width, result.height, Bitmap.Config.ARGB_8888) + fun encode( + data: String, + size: Int, + isInverted: Boolean = false, + hasTransparentBackground: Boolean = true, + dark: Int = Color.BLACK, + light: Int = Color.WHITE, + ): Bitmap? = runCatching { + val hints = hashMapOf( + EncodeHintType.MARGIN to 0, + EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M + ) + val color = if (isInverted) light else dark + val background = if (isInverted) dark else light + val result = QRCodeWriter().encode(data, BarcodeFormat.QR_CODE, size, size, hints) + Bitmap.createBitmap(result.width, result.height, Bitmap.Config.ARGB_8888).apply { for (y in 0 until result.height) { for (x in 0 until result.width) { - if (result.get(x, y)) { - bitmap.setPixel(x, y, if (isInverted) Color.WHITE else Color.BLACK) - } else if (!hasTransparentBackground) { - bitmap.setPixel(x, y, if (isInverted) Color.BLACK else Color.WHITE) + when { + result.get(x, y) -> setPixel(x, y, color) + !hasTransparentBackground -> setPixel(x, y, background) } } } - return bitmap - } catch (e: WriterException) { - return Bitmap.createBitmap(512, 512, Bitmap.Config.ARGB_8888) } - } -} \ No newline at end of file + }.getOrNull() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt index c0477825fd..a7ba6027d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt @@ -16,6 +16,7 @@ import androidx.annotation.DimenRes import network.loki.messenger.R import org.session.libsession.utilities.getColorFromAttr import android.view.inputmethod.InputMethodManager +import android.widget.EditText import androidx.annotation.AttrRes import androidx.annotation.ColorRes import androidx.core.graphics.applyCanvas @@ -111,3 +112,11 @@ fun Size.coerceAtMost(longestWidth: Int): Size = height.coerceAtMost(longestWidth).let { Size((it * aspect).roundToInt(), it) } } } + +fun EditText.addTextChangedListener(listener: (String) -> Unit) { + addTextChangedListener(object: SimpleTextWatcher() { + override fun onTextChanged(text: String) { + listener(text) + } + }) +} diff --git a/app/src/main/res/drawable/session_id_text_view_background.xml b/app/src/main/res/drawable/account_id_text_view_background.xml similarity index 100% rename from app/src/main/res/drawable/session_id_text_view_background.xml rename to app/src/main/res/drawable/account_id_text_view_background.xml diff --git a/app/src/main/res/drawable/conversation_view_search__background.xml b/app/src/main/res/drawable/conversation_view_search__background.xml new file mode 100644 index 0000000000..50e38698b4 --- /dev/null +++ b/app/src/main/res/drawable/conversation_view_search__background.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/emoji_tada_large.xml b/app/src/main/res/drawable/emoji_tada_large.xml new file mode 100644 index 0000000000..ed802646ff --- /dev/null +++ b/app/src/main/res/drawable/emoji_tada_large.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_circle_question_mark.xml b/app/src/main/res/drawable/ic_circle_question_mark.xml new file mode 100644 index 0000000000..9bc2b817f1 --- /dev/null +++ b/app/src/main/res/drawable/ic_circle_question_mark.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_dialog_x.xml b/app/src/main/res/drawable/ic_dialog_x.xml new file mode 100644 index 0000000000..a65f2abb88 --- /dev/null +++ b/app/src/main/res/drawable/ic_dialog_x.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_logo_large.xml b/app/src/main/res/drawable/ic_logo_large.xml new file mode 100644 index 0000000000..b494b17663 --- /dev/null +++ b/app/src/main/res/drawable/ic_logo_large.xml @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_outline_bookmark_border_24.xml b/app/src/main/res/drawable/ic_outline_bookmark_border_24.xml deleted file mode 100644 index 0cb95b7706..0000000000 --- a/app/src/main/res/drawable/ic_outline_bookmark_border_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_path_yellow.xml b/app/src/main/res/drawable/ic_path_yellow.xml new file mode 100644 index 0000000000..04f0c51545 --- /dev/null +++ b/app/src/main/res/drawable/ic_path_yellow.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_recovery_phrase.xml b/app/src/main/res/drawable/ic_recovery_phrase.xml deleted file mode 100644 index 3d5bc18e21..0000000000 --- a/app/src/main/res/drawable/ic_recovery_phrase.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_shield_outline.xml b/app/src/main/res/drawable/ic_shield_outline.xml new file mode 100644 index 0000000000..3db98f53d0 --- /dev/null +++ b/app/src/main/res/drawable/ic_shield_outline.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_status.xml b/app/src/main/res/drawable/ic_status.xml new file mode 100644 index 0000000000..7b19ad1413 --- /dev/null +++ b/app/src/main/res/drawable/ic_status.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/search_background.xml b/app/src/main/res/drawable/search_background.xml index a2090ca6bc..58da2d8522 100644 --- a/app/src/main/res/drawable/search_background.xml +++ b/app/src/main/res/drawable/search_background.xml @@ -2,5 +2,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/session.xml b/app/src/main/res/drawable/session.xml new file mode 100644 index 0000000000..2c6e1081f7 --- /dev/null +++ b/app/src/main/res/drawable/session.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_logo.xml b/app/src/main/res/drawable/session_logo.xml index f88a4f21a9..b2f931990f 100644 --- a/app/src/main/res/drawable/session_logo.xml +++ b/app/src/main/res/drawable/session_logo.xml @@ -1,9 +1,9 @@ - - - - - + + diff --git a/app/src/main/res/drawable/session_shield.xml b/app/src/main/res/drawable/session_shield.xml new file mode 100644 index 0000000000..a7c6d1a24a --- /dev/null +++ b/app/src/main/res/drawable/session_shield.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout-sw400dp/activity_display_name.xml b/app/src/main/res/layout-sw400dp/activity_display_name.xml deleted file mode 100644 index d62faca064..0000000000 --- a/app/src/main/res/layout-sw400dp/activity_display_name.xml +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - -