diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 931a654365..e9d3c6ab2f 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -25,7 +25,7 @@ jobs: - variant: 'play' run_test: true steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: submodules: 'recursive' @@ -54,14 +54,14 @@ jobs: - name: Upload build reports regardless if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: build-reports-${{ matrix.variant }}-${{ matrix.build_type }} path: app/build/reports if-no-files-found: ignore - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: session-${{ matrix.variant }}-${{ matrix.build_type }} path: app/build/outputs/apk/${{ matrix.variant }}/${{ matrix.build_type }}/*-universal*apk diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3744e8b8f2..0db6e19270 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ configurations.configureEach { exclude(module = "commons-logging") } -val canonicalVersionCode = 432 -val canonicalVersionName = "1.29.3" +val canonicalVersionCode = 433 +val canonicalVersionName = "1.30.0" val postFixSize = 10 val abiPostFix = mapOf( @@ -105,7 +105,6 @@ protobuf { android { namespace = "network.loki.messenger" - useLibrary("org.apache.http.legacy") compileOptions { sourceCompatibility = JavaVersion.VERSION_21 @@ -181,13 +180,12 @@ android { matchingFallbacks += "release" signingConfig = signingConfigs.getByName("debug") - applicationIdSuffix = ".$name" devNetDefaultOn(false) enablePermissiveNetworkSecurityConfig(true) setAlternativeAppName("Session QA") - setAuthorityPostfix(".qa") + setAuthorityPostfix("") } create("automaticQa") { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0ddf065ec3..cce3a226fb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -190,9 +190,6 @@ android:screenOrientation="portrait" /> - diff --git a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt index f30b4aef98..1d99e66c52 100644 --- a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -1,6 +1,5 @@ package org.session.libsession.database -import android.content.Context import android.net.Uri import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.KeyPair @@ -11,7 +10,6 @@ import org.session.libsession.messaging.jobs.MessageSendJob import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.messaging.messages.visible.Attachment -import org.session.libsession.messaging.messages.visible.Profile import org.session.libsession.messaging.messages.visible.Reaction import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId @@ -26,7 +24,6 @@ import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceAttachmentPointer -import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord @@ -38,12 +35,8 @@ interface StorageProtocol { // General fun getUserPublicKey(): String? fun getUserED25519KeyPair(): KeyPair? - fun getUserX25519KeyPair(): ECKeyPair + fun getUserX25519KeyPair(): KeyPair fun getUserBlindedAccountId(serverPublicKey: String): AccountId? - fun getUserProfile(): Profile - - // Signal - fun getOrGenerateRegistrationID(): Int // Jobs fun persistJob(job: Job) @@ -120,8 +113,6 @@ interface StorageProtocol { fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) fun removeAllClosedGroupEncryptionKeyPairs(groupPublicKey: String) - fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, - members: Collection, admins: Collection, threadID: Long, sentTimestamp: Long): Long? fun isLegacyClosedGroup(publicKey: String): Boolean fun getClosedGroupEncryptionKeyPairs(groupPublicKey: String): MutableList fun getLatestClosedGroupEncryptionKeyPair(groupPublicKey: String): ECKeyPair? @@ -155,6 +146,8 @@ interface StorageProtocol { fun trimThreadBefore(threadID: Long, timestamp: Long) fun getMessageCount(threadID: Long): Long fun getTotalPinned(): Int + suspend fun getTotalSentProBadges(): Int + suspend fun getTotalSentLongMessages(): Int fun setPinned(address: Address, isPinned: Boolean) fun isRead(threadId: Long) : Boolean fun setThreadCreationDate(threadId: Long, newDate: Long) diff --git a/app/src/main/java/org/session/libsession/messaging/file_server/FileServer.kt b/app/src/main/java/org/session/libsession/messaging/file_server/FileServer.kt index de34f2407d..1d376ea8c9 100644 --- a/app/src/main/java/org/session/libsession/messaging/file_server/FileServer.kt +++ b/app/src/main/java/org/session/libsession/messaging/file_server/FileServer.kt @@ -3,11 +3,11 @@ package org.session.libsession.messaging.file_server import kotlinx.serialization.Serializable import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl -import org.session.libsession.utilities.serializable.HttpSerializer +import org.session.libsession.utilities.serializable.HttpUrlSerializer @Serializable data class FileServer( - @Serializable(with = HttpSerializer::class) + @Serializable(with = HttpUrlSerializer::class) val url: HttpUrl, val ed25519PublicKeyHex: String ) { diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt index a6747b3b10..46f473e210 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -1,10 +1,8 @@ package org.session.libsession.messaging.jobs -import android.content.Context import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import dagger.hilt.android.qualifiers.ApplicationContext import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.session.libsession.database.MessageDataProvider @@ -16,7 +14,6 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.utilities.Data import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.DecodedAudio import org.session.libsession.utilities.InputStreamMediaDataSource @@ -252,21 +249,18 @@ class AttachmentDownloadJob @AssistedInject constructor( return KEY } - class DeserializeFactory(private val factory: Factory) : Job.DeserializeFactory { + @AssistedFactory + abstract class Factory : Job.DeserializeFactory { + abstract fun create( + @Assisted("attachmentID") attachmentID: Long, + mmsMessageId: Long + ): AttachmentDownloadJob override fun create(data: Data): AttachmentDownloadJob { - return factory.create( + return create( attachmentID = data.getLong(ATTACHMENT_ID_KEY), mmsMessageId = data.getLong(TS_INCOMING_MESSAGE_ID_KEY) ) } } - - @AssistedFactory - interface Factory { - fun create( - @Assisted("attachmentID") attachmentID: Long, - mmsMessageId: Long - ): AttachmentDownloadJob - } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt index bd31a3c3f1..86ce01eec7 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt @@ -15,7 +15,6 @@ import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.Data -import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.DecodedAudio import org.session.libsession.utilities.InputStreamMediaDataSource @@ -38,6 +37,7 @@ class AttachmentUploadJob @AssistedInject constructor( private val attachmentProcessor: AttachmentProcessor, private val preferences: TextSecurePreferences, private val fileServerApi: FileServerApi, + private val messageSender: MessageSender, ) : Job { override var delegate: JobDelegate? = null override var id: String? = null @@ -219,7 +219,7 @@ class AttachmentUploadJob @AssistedInject constructor( private fun failAssociatedMessageSendJob(e: Exception) { val messageSendJob = storage.getMessageSendJob(messageSendJobID) - MessageSender.handleFailedMessageSend(this.message, e) + messageSender.handleFailedMessageSend(this.message, e) if (messageSendJob != null) { storage.markJobAsFailedPermanently(messageSendJobID) } @@ -244,7 +244,14 @@ class AttachmentUploadJob @AssistedInject constructor( return KEY } - class DeserializeFactory(private val factory: Factory): Job.DeserializeFactory { + @AssistedFactory + abstract class Factory : Job.DeserializeFactory { + abstract fun create( + attachmentID: Long, + @Assisted("threadID") threadID: String, + message: Message, + messageSendJobID: String + ): AttachmentUploadJob override fun create(data: Data): AttachmentUploadJob? { val serializedMessage = data.getByteArray(MESSAGE_KEY) @@ -259,7 +266,7 @@ class AttachmentUploadJob @AssistedInject constructor( return null } input.close() - return factory.create( + return create( attachmentID = data.getLong(ATTACHMENT_ID_KEY), threadID = data.getString(THREAD_ID_KEY)!!, message = message, @@ -267,14 +274,4 @@ class AttachmentUploadJob @AssistedInject constructor( ) } } - - @AssistedFactory - interface Factory { - fun create( - attachmentID: Long, - @Assisted("threadID") threadID: String, - message: Message, - messageSendJobID: String - ): AttachmentUploadJob - } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt index fc1bbfac42..410acddafc 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.PRIORITY_HIDDEN import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Message @@ -51,6 +51,7 @@ data class MessageReceiveParameters( val closedGroup: Destination.ClosedGroup? = null ) +@Deprecated("BatchMessageReceiveJob is now only here so that existing persisted jobs can be processed.") class BatchMessageReceiveJob @AssistedInject constructor( @Assisted private val messages: List, @Assisted val fromCommunity: Address.Community?, // The community the messages are received in, if any @@ -62,6 +63,7 @@ class BatchMessageReceiveJob @AssistedInject constructor( private val messageNotifier: MessageNotifier, private val threadDatabase: ThreadDatabase, private val recipientRepository: RecipientRepository, + private val messageReceiver: MessageReceiver, ) : Job { override var delegate: JobDelegate? = null @@ -105,6 +107,7 @@ class BatchMessageReceiveJob @AssistedInject constructor( fromCommunity = fromCommunity, threadDatabase = threadDatabase, recipientRepository = recipientRepository, + messageReceiver = messageReceiver, ) } @@ -141,7 +144,7 @@ class BatchMessageReceiveJob @AssistedInject constructor( message.groupPublicKey == null && // not a group message.openGroupServerMessageID == null && // not a community // not marked as hidden - configs.contacts.get(message.senderOrSync)?.priority == ConfigBase.PRIORITY_HIDDEN && + configs.contacts.get(message.senderOrSync)?.priority == PRIORITY_HIDDEN && // the message's sentTimestamp is earlier than the sentTimestamp of the last config message.sentTimestamp!! < contactConfigTimestamp } @@ -157,7 +160,7 @@ class BatchMessageReceiveJob @AssistedInject constructor( messages.forEach { messageParameters -> val (data, serverHash, openGroupMessageServerID) = messageParameters try { - val (message, proto) = MessageReceiver.parse( + val (message, proto) = messageReceiver.parse( data, openGroupMessageServerID, openGroupPublicKey = serverPublicKey, @@ -358,7 +361,8 @@ class BatchMessageReceiveJob @AssistedInject constructor( @AssistedFactory abstract class Factory : Job.DeserializeFactory { - abstract fun create( + @Deprecated("New code should try to handle message directly instead of creating this job") + protected abstract fun create( messages: List, fromCommunity: Address.Community?, ): BatchMessageReceiveJob diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt index 1a4c5cc9f9..70157d8ef1 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt @@ -2,6 +2,9 @@ package org.session.libsession.messaging.jobs import android.widget.Toast import com.google.protobuf.ByteString +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -16,13 +19,19 @@ import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.Data import org.session.libsession.messaging.utilities.MessageAuthentication.buildGroupInviteSignature import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.getGroup import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInviteMessage import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log -class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array) : Job { +class InviteContactsJob @AssistedInject constructor( + @Assisted val groupSessionId: String, + @Assisted val memberSessionIds: Array, + private val configFactory: ConfigFactoryProtocol, + private val messageSender: MessageSender, +) : Job { companion object { const val KEY = "InviteContactJob" @@ -37,8 +46,7 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< override val maxFailureCount: Int = 1 override suspend fun execute(dispatcherName: String) { - val configs = MessagingModuleConfiguration.shared.configFactory - val group = requireNotNull(configs.getGroup(AccountId(groupSessionId))) { + val group = requireNotNull(configFactory.getGroup(AccountId(groupSessionId))) { "Group must exist to invite" } @@ -54,7 +62,7 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< runCatching { // Make the request for this member val memberId = AccountId(memberSessionId) - val (groupName, subAccount) = configs.withMutableGroupConfigs(sessionId) { configs -> + val (groupName, subAccount) = configFactory.withMutableGroupConfigs(sessionId) { configs -> configs.groupInfo.getName() to configs.groupKeys.makeSubAccount(memberSessionId) } @@ -76,14 +84,14 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< sentTimestamp = timestamp } - MessageSender.sendNonDurably(update, Destination.Contact(memberSessionId), false) + messageSender.sendNonDurably(update, Destination.Contact(memberSessionId), false) } } } val results = memberSessionIds.zip(requests.awaitAll()) - configs.withMutableGroupConfigs(sessionId) { configs -> + configFactory.withMutableGroupConfigs(sessionId) { configs -> results.forEach { (memberSessionId, result) -> configs.groupMembers.get(memberSessionId)?.let { member -> if (result.isFailure) { @@ -96,8 +104,8 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< } } - val groupName = configs.withGroupConfigs(sessionId) { it.groupInfo.getName() } - ?: configs.getGroup(sessionId)?.name + val groupName = configFactory.withGroupConfigs(sessionId) { it.groupInfo.getName() } + ?: configFactory.getGroup(sessionId)?.name // Gather all the exceptions, while keeping track of the invitee account IDs val failures = results.mapNotNull { (id, result) -> @@ -140,4 +148,20 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< override fun getFactoryKey(): String = KEY + @AssistedFactory + abstract class Factory : Job.DeserializeFactory { + abstract fun create( + groupSessionId: String, + memberSessionIds: Array, + ): InviteContactsJob + + override fun create(data: Data): InviteContactsJob? { + val groupSessionId = data.getString(GROUP) ?: return null + val memberSessionIds = data.getStringArray(MEMBER) ?: return null + return create( + groupSessionId = groupSessionId, + memberSessionIds = memberSessionIds, + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt index 238b7edb04..1cb9e88bfa 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt @@ -34,6 +34,7 @@ class MessageSendJob @AssistedInject constructor( private val messageDataProvider: MessageDataProvider, private val storage: StorageProtocol, private val configFactory: ConfigFactoryProtocol, + private val messageSender: MessageSender, ) : Job { object AwaitingAttachmentUploadException : Exception("Awaiting attachment upload.") @@ -97,7 +98,7 @@ class MessageSendJob @AssistedInject constructor( } } - MessageSender.sendNonDurably(this@MessageSendJob.message, destination, isSync) + messageSender.sendNonDurably(this@MessageSendJob.message, destination, isSync) this.handleSuccess(dispatcherName) statusCallback?.trySend(Result.success(Unit)) @@ -173,7 +174,14 @@ class MessageSendJob @AssistedInject constructor( return KEY } - class DeserializeFactory(private val factory: Factory) : Job.DeserializeFactory { + + @AssistedFactory + abstract class Factory : Job.DeserializeFactory { + abstract fun create( + message: Message, + destination: Destination, + statusCallback: SendChannel>? = null + ): MessageSendJob override fun create(data: Data): MessageSendJob? { val serializedMessage = data.getByteArray(MESSAGE_KEY) @@ -201,20 +209,11 @@ class MessageSendJob @AssistedInject constructor( } destinationInput.close() // Return - return factory.create( + return create( message = message, destination = destination, statusCallback = null ) } } - - @AssistedFactory - interface Factory { - fun create( - message: Message, - destination: Destination, - statusCallback: SendChannel>? = null - ): MessageSendJob - } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt b/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt index cd0468d9f2..e1780ce19f 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt @@ -8,18 +8,20 @@ class SessionJobManagerFactories @Inject constructor( private val batchFactory: BatchMessageReceiveJob.Factory, private val trimThreadFactory: TrimThreadJob.Factory, private val messageSendJobFactory: MessageSendJob.Factory, - private val deleteJobFactory: OpenGroupDeleteJob.Factory + private val deleteJobFactory: OpenGroupDeleteJob.Factory, + private val inviteContactsJobFactory: InviteContactsJob.Factory, ) { fun getSessionJobFactories(): Map> { return mapOf( - AttachmentDownloadJob.KEY to AttachmentDownloadJob.DeserializeFactory(attachmentDownloadJobFactory), - AttachmentUploadJob.KEY to AttachmentUploadJob.DeserializeFactory(attachmentUploadJobFactory), - MessageSendJob.KEY to MessageSendJob.DeserializeFactory(messageSendJobFactory), + AttachmentDownloadJob.KEY to attachmentDownloadJobFactory, + AttachmentUploadJob.KEY to attachmentUploadJobFactory, + MessageSendJob.KEY to messageSendJobFactory, NotifyPNServerJob.KEY to NotifyPNServerJob.DeserializeFactory(), TrimThreadJob.KEY to trimThreadFactory, BatchMessageReceiveJob.KEY to batchFactory, OpenGroupDeleteJob.KEY to deleteJobFactory, + InviteContactsJob.KEY to inviteContactsJobFactory, ) } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/messages/Destination.kt b/app/src/main/java/org/session/libsession/messaging/messages/Destination.kt index bb8c29a0ce..5ddbad9fa1 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/Destination.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/Destination.kt @@ -8,12 +8,6 @@ sealed class Destination { data class Contact(var publicKey: String) : Destination() { internal constructor(): this("") } - data class LegacyClosedGroup(var groupPublicKey: String) : Destination() { - internal constructor(): this("") - } - data class LegacyOpenGroup(var roomToken: String, var server: String) : Destination() { - internal constructor(): this("", "") - } data class ClosedGroup(var publicKey: String): Destination() { internal constructor(): this("") } @@ -39,9 +33,6 @@ sealed class Destination { is Address.Standard -> { Contact(address.address) } - is Address.LegacyGroup -> { - LegacyClosedGroup(address.groupPublicKeyHex) - } is Address.Community -> { OpenGroup(roomToken = address.room, server = address.serverUrl, fileIds = fileIds) } @@ -63,9 +54,10 @@ sealed class Destination { is Address.Group -> { ClosedGroup(address.accountId.hexString) } - else -> { - throw Exception("TODO: Handle legacy closed groups.") - } + + is Address.Blinded, + is Address.LegacyGroup, + is Address.Unknown -> error("Unsupported address as destination: $address") } } } diff --git a/app/src/main/java/org/session/libsession/messaging/messages/Message.kt b/app/src/main/java/org/session/libsession/messaging/messages/Message.kt index 223ad0ea6f..38d0c2eedc 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/Message.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/Message.kt @@ -1,7 +1,7 @@ package org.session.libsession.messaging.messages import network.loki.messenger.libsession_util.util.ExpiryMode -import org.session.libsession.database.StorageProtocol +import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.visible.VisibleMessage @@ -48,18 +48,32 @@ abstract class Message { && sender != null && recipient != null - abstract fun toProto(): SignalServiceProtos.Content? + protected abstract fun buildProto( + builder: SignalServiceProtos.Content.Builder, + messageDataProvider: MessageDataProvider + ) - abstract fun shouldDiscardIfBlocked(): Boolean - - fun SignalServiceProtos.Content.Builder.applyExpiryMode() = apply { - expirationTimerSeconds = expiryMode.expirySeconds.toInt() - expirationType = when (expiryMode) { + fun toProto( + builder: SignalServiceProtos.Content.Builder, + messageDataProvider: MessageDataProvider + ) { + // First apply common message data + // * Expiry mode + builder.expirationTimerSeconds = expiryMode.expirySeconds.toInt() + builder.expirationType = when (expiryMode) { is ExpiryMode.AfterSend -> ExpirationType.DELETE_AFTER_SEND is ExpiryMode.AfterRead -> ExpirationType.DELETE_AFTER_READ else -> ExpirationType.UNKNOWN } + + // * Timestamps + builder.setSigTimestampMs(sentTimestamp!!) + + // Then ask the subclasses to build their specific proto + buildProto(builder, messageDataProvider) } + + abstract fun shouldDiscardIfBlocked(): Boolean } inline fun M.copyExpiration(proto: SignalServiceProtos.Content): M = apply { @@ -72,20 +86,10 @@ inline fun M.copyExpiration(proto: SignalServiceProtos.Cont } } -fun SignalServiceProtos.Content.expiryMode(): ExpiryMode = - (takeIf { it.hasExpirationTimerSeconds() }?.expirationTimerSeconds ?: dataMessage?.expireTimerSeconds)?.let { duration -> - when (expirationType.takeIf { duration > 0 }) { - ExpirationType.DELETE_AFTER_SEND -> ExpiryMode.AfterSend(duration.toLong()) - ExpirationType.DELETE_AFTER_READ -> ExpiryMode.AfterRead(duration.toLong()) - else -> ExpiryMode.NONE - } - } ?: ExpiryMode.NONE - /** * Apply ExpiryMode from the current setting. */ inline fun M.applyExpiryMode(recipientAddress: Address): M = apply { expiryMode = MessagingModuleConfiguration.shared.recipientRepository.getRecipientSync(recipientAddress) - ?.expiryMode?.coerceSendToRead(coerceDisappearAfterSendToRead) - ?: ExpiryMode.NONE + .expiryMode.coerceSendToRead(coerceDisappearAfterSendToRead) } diff --git a/app/src/main/java/org/session/libsession/messaging/messages/ProfileUpdateHandler.kt b/app/src/main/java/org/session/libsession/messaging/messages/ProfileUpdateHandler.kt index 1ac62e171e..86a6b8ec96 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/ProfileUpdateHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/ProfileUpdateHandler.kt @@ -1,17 +1,22 @@ package org.session.libsession.messaging.messages +import com.google.protobuf.ByteString +import network.loki.messenger.libsession_util.protocol.ProProfileFeature import network.loki.messenger.libsession_util.util.BaseCommunityInfo +import network.loki.messenger.libsession_util.util.BitSet +import network.loki.messenger.libsession_util.util.Conversation import network.loki.messenger.libsession_util.util.UserPic -import org.session.libsession.messaging.messages.visible.Profile import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.updateContact +import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.BlindMappingRepository import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.RecipientSettingsDatabase +import org.thoughtcrime.securesms.database.model.RecipientSettings import org.thoughtcrime.securesms.util.DateUtils.Companion.secondsToInstant import org.thoughtcrime.securesms.util.DateUtils.Companion.toEpochSeconds import java.time.Instant @@ -56,11 +61,14 @@ class ProfileUpdateHandler @Inject constructor( val standardSender = unblinded ?: (senderAddress as? Address.Standard) if (standardSender != null && (!updates.name.isNullOrBlank() || updates.pic != null)) { configFactory.withMutableUserConfigs { configs -> + var shouldUpdate = false configs.contacts.updateContact(standardSender) { - if (shouldUpdateProfile( + shouldUpdate = shouldUpdateProfile( lastUpdated = profileUpdatedEpochSeconds.secondsToInstant(), newUpdateTime = updates.profileUpdateTime - )) { + ) + + if (shouldUpdate) { if (updates.name != null) { name = updates.name } @@ -69,6 +77,10 @@ class ProfileUpdateHandler @Inject constructor( profilePicture = updates.pic } + if (updates.proFeatures != null) { + proFeatures = updates.proFeatures + } + if (updates.profileUpdateTime != null) { profileUpdatedEpochSeconds = updates.profileUpdateTime.toEpochSeconds() } @@ -77,13 +89,20 @@ class ProfileUpdateHandler @Inject constructor( Log.d(TAG, "Ignoring contact profile update for ${standardSender.debugString}, no changes detected") } } + + if (shouldUpdate) { + configs.convoInfoVolatile.set( + configs.convoInfoVolatile.getOrConstructOneToOne(standardSender.accountId.hexString) + .copy(proProofInfo = updates.proProof) + ) + } } } // If we have a blinded address, we need to look at if we have a blinded contact to update if (senderAddress is Address.Blinded && (updates.pic != null || !updates.name.isNullOrBlank())) { configFactory.withMutableUserConfigs { configs -> - configs.contacts.getBlinded(senderAddress.blindedId.hexString)?.let { c -> + val shouldUpdate = configs.contacts.getBlinded(senderAddress.blindedId.hexString)?.let { c -> if (shouldUpdateProfile( lastUpdated = c.profileUpdatedEpochSeconds.secondsToInstant(), newUpdateTime = updates.profileUpdateTime @@ -96,12 +115,26 @@ class ProfileUpdateHandler @Inject constructor( c.name = updates.name } + if (updates.proFeatures != null) { + c.proFeatures = updates.proFeatures + } + if (updates.profileUpdateTime != null) { c.profileUpdatedEpochSeconds = updates.profileUpdateTime.toEpochSeconds() } configs.contacts.setBlinded(c) + true + } else { + false } + } == true + + if (shouldUpdate) { + configs.convoInfoVolatile.set( + configs.convoInfoVolatile.getOrConstructedBlindedOneToOne(senderAddress.blindedId.hexString) + .copy(proProofInfo = updates.proProof) + ) } } } @@ -121,7 +154,13 @@ class ProfileUpdateHandler @Inject constructor( r.copy( name = updates.name ?: r.name, profilePic = updates.pic ?: r.profilePic, - blocksCommunityMessagesRequests = updates.blocksCommunityMessageRequests ?: r.blocksCommunityMessagesRequests + blocksCommunityMessagesRequests = updates.blocksCommunityMessageRequests ?: r.blocksCommunityMessagesRequests, + proData = updates.proProof?.let { + RecipientSettings.ProData( + info = it, + features = updates.proFeatures ?: BitSet() + ) + }, ) } else if (updates.blocksCommunityMessageRequests != null && r.blocksCommunityMessagesRequests != updates.blocksCommunityMessageRequests) { @@ -154,6 +193,8 @@ class ProfileUpdateHandler @Inject constructor( // Name to update, must be non-blank if provided. val name: String? = null, val pic: UserPic? = null, + val proProof: Conversation.ProProofInfo? = null, + val proFeatures: BitSet? = null, val blocksCommunityMessageRequests: Boolean? = null, val profileUpdateTime: Instant?, ) { @@ -164,44 +205,69 @@ class ProfileUpdateHandler @Inject constructor( } companion object { - fun create( - name: String? = null, - picUrl: String?, - picKey: ByteArray?, - blocksCommunityMessageRequests: Boolean? = null, - proStatus: Boolean? = null, - profileUpdateTime: Instant? - ): Updates? { - val hasNameUpdate = !name.isNullOrBlank() - val pic = when { - picUrl == null -> null - picUrl.isBlank() || picKey == null || picKey.size !in VALID_PROFILE_KEY_LENGTH -> UserPic.DEFAULT - else -> UserPic(picUrl, picKey) + fun create(content: SignalServiceProtos.Content): Updates? { + val profile: SignalServiceProtos.DataMessage.LokiProfile + val profilePicKey: ByteString? + + when { + content.hasDataMessage() && content.dataMessage.hasProfile() -> { + profile = content.dataMessage.profile + profilePicKey = + if (content.dataMessage.hasProfileKey()) content.dataMessage.profileKey else null + } + + content.hasMessageRequestResponse() && content.messageRequestResponse.hasProfile() -> { + profile = content.messageRequestResponse.profile + profilePicKey = + if (content.messageRequestResponse.hasProfileKey()) content.messageRequestResponse.profileKey else null + } + + else -> { + // No profile found, not updating. + // This is different from having an empty profile, which is a valid update. + return null + } + } + + val pic = if (profile.hasProfilePicture()) { + if (!profile.profilePicture.isNullOrBlank() && profilePicKey != null && + profilePicKey.size() in VALID_PROFILE_KEY_LENGTH) { + UserPic( + url = profile.profilePicture, + key = profilePicKey.toByteArray() + ) + } else { + UserPic.DEFAULT // Clear the profile picture + } + } else { + null // No update to profile picture } - if (!hasNameUpdate && pic == null && blocksCommunityMessageRequests == null && proStatus == null) { + val name = if (profile.hasDisplayName()) profile.displayName else null + val blocksCommunityMessageRequests = if (content.hasDataMessage() && + content.dataMessage.hasBlocksCommunityMessageRequests()) { + content.dataMessage.blocksCommunityMessageRequests + } else { + null + } + + if (name == null && pic == null && blocksCommunityMessageRequests == null) { + // Nothing is updated.. return null } return Updates( - name = if (hasNameUpdate) name else null, + name = name, pic = pic, blocksCommunityMessageRequests = blocksCommunityMessageRequests, - profileUpdateTime = profileUpdateTime + profileUpdateTime = if (profile.hasLastProfileUpdateSeconds()) { + Instant.ofEpochSecond(profile.lastProfileUpdateSeconds) + } else { + null + } ) } - fun Profile.toUpdates( - blocksCommunityMessageRequests: Boolean? = null, - ): Updates? { - return create( - name = this.displayName, - picUrl = this.profilePictureURL, - picKey = this.profileKey, - blocksCommunityMessageRequests = blocksCommunityMessageRequests, - profileUpdateTime = this.profileUpdated - ) - } } } diff --git a/app/src/main/java/org/session/libsession/messaging/messages/control/CallMessage.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/CallMessage.kt index 1d565acc62..f4c9781730 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/control/CallMessage.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/control/CallMessage.kt @@ -1,13 +1,12 @@ package org.session.libsession.messaging.messages.control -import org.session.libsession.messaging.messages.applyExpiryMode +import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.copyExpiration import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.ANSWER import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.END_CALL import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.OFFER import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.PRE_OFFER -import org.session.libsignal.utilities.Log import java.util.UUID class CallMessage(): ControlMessage() { @@ -77,23 +76,16 @@ class CallMessage(): ControlMessage() { } } - override fun toProto(): SignalServiceProtos.Content? { - val nonNullType = type ?: run { - Log.w(TAG,"Couldn't construct call message request proto from: $this") - return null - } - - val callMessage = SignalServiceProtos.CallMessage.newBuilder() - .setType(nonNullType) + protected override fun buildProto( + builder: SignalServiceProtos.Content.Builder, + messageDataProvider: MessageDataProvider + ) { + builder.callMessageBuilder + .setType(type!!) .addAllSdps(sdps) .addAllSdpMLineIndexes(sdpMLineIndexes) .addAllSdpMids(sdpMids) .setUuid(callId!!.toString()) - - return SignalServiceProtos.Content.newBuilder() - .applyExpiryMode() - .setCallMessage(callMessage) - .build() } override fun equals(other: Any?): Boolean { diff --git a/app/src/main/java/org/session/libsession/messaging/messages/control/DataExtractionNotification.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/DataExtractionNotification.kt index 45c1b2fe3e..79872cc635 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/control/DataExtractionNotification.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/control/DataExtractionNotification.kt @@ -1,8 +1,8 @@ package org.session.libsession.messaging.messages.control +import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.copyExpiration import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.utilities.Log class DataExtractionNotification() : ControlMessage() { var kind: Kind? = null @@ -53,28 +53,17 @@ class DataExtractionNotification() : ControlMessage() { } } - override fun toProto(): SignalServiceProtos.Content? { - val kind = kind - if (kind == null) { - Log.w(TAG, "Couldn't construct data extraction notification proto from: $this") - return null - } - try { - val dataExtractionNotification = SignalServiceProtos.DataExtractionNotification.newBuilder() - when(kind) { - is Kind.Screenshot -> dataExtractionNotification.type = SignalServiceProtos.DataExtractionNotification.Type.SCREENSHOT - is Kind.MediaSaved -> { - dataExtractionNotification.type = SignalServiceProtos.DataExtractionNotification.Type.MEDIA_SAVED - dataExtractionNotification.timestampMs = kind.timestamp - } + protected override fun buildProto( + builder: SignalServiceProtos.Content.Builder, + messageDataProvider: MessageDataProvider + ) { + val dataExtractionNotification = builder.dataExtractionNotificationBuilder + when (val kind = kind!!) { + is Kind.Screenshot -> dataExtractionNotification.type = SignalServiceProtos.DataExtractionNotification.Type.SCREENSHOT + is Kind.MediaSaved -> { + dataExtractionNotification.type = SignalServiceProtos.DataExtractionNotification.Type.MEDIA_SAVED + dataExtractionNotification.timestampMs = kind.timestamp } - return SignalServiceProtos.Content.newBuilder() - .setDataExtractionNotification(dataExtractionNotification.build()) - .applyExpiryMode() - .build() - } catch (e: Exception) { - Log.w(TAG, "Couldn't construct data extraction notification proto from: $this") - return null } } } diff --git a/app/src/main/java/org/session/libsession/messaging/messages/control/ExpirationTimerUpdate.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/ExpirationTimerUpdate.kt index 1e39b4d593..c7d1f21903 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/control/ExpirationTimerUpdate.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/control/ExpirationTimerUpdate.kt @@ -1,10 +1,10 @@ package org.session.libsession.messaging.messages.control +import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.copyExpiration import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos.DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE -import org.session.libsignal.utilities.Log /** In the case of a sync message, the public key of the person the message was targeted at. * @@ -25,21 +25,16 @@ data class ExpirationTimerUpdate(var syncTarget: String? = null, val isGroup: Bo } } - override fun toProto(): SignalServiceProtos.Content? { - val dataMessageProto = SignalServiceProtos.DataMessage.newBuilder().apply { - flags = EXPIRATION_TIMER_UPDATE_VALUE - expireTimerSeconds = expiryMode.expirySeconds.toInt() - } - // Sync target - syncTarget?.let { dataMessageProto.syncTarget = it } - return try { - SignalServiceProtos.Content.newBuilder() - .setDataMessage(dataMessageProto) - .applyExpiryMode() - .build() - } catch (e: Exception) { - Log.w(TAG, "Couldn't construct expiration timer update proto from: $this", e) - null - } + protected override fun buildProto( + builder: SignalServiceProtos.Content.Builder, + messageDataProvider: MessageDataProvider + ) { + builder.dataMessageBuilder + .setFlags(EXPIRATION_TIMER_UPDATE_VALUE) + .setExpireTimerSeconds(expiryMode.expirySeconds.toInt()) + .also { builder -> + // Sync target + syncTarget?.let { builder.syncTarget = it } + } } } diff --git a/app/src/main/java/org/session/libsession/messaging/messages/control/GroupUpdated.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/GroupUpdated.kt index 28120be716..16fa0cba23 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/control/GroupUpdated.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/control/GroupUpdated.kt @@ -1,13 +1,11 @@ package org.session.libsession.messaging.messages.control -import org.session.libsession.messaging.messages.visible.Profile +import org.session.libsession.database.MessageDataProvider import org.session.libsignal.protos.SignalServiceProtos.Content -import org.session.libsignal.protos.SignalServiceProtos.DataMessage import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage class GroupUpdated @JvmOverloads constructor( val inner: GroupUpdateMessage = GroupUpdateMessage.getDefaultInstance(), - val profile: Profile? = null ): ControlMessage() { override fun isValid(): Boolean { @@ -26,18 +24,13 @@ class GroupUpdated @JvmOverloads constructor( if (message.hasDataMessage() && message.dataMessage.hasGroupUpdateMessage()) GroupUpdated( inner = message.dataMessage.groupUpdateMessage, - profile = Profile.fromProto(message.dataMessage) ) else null } - override fun toProto(): Content { - val dataMessage = DataMessage.newBuilder() + override fun buildProto(builder: Content.Builder, messageDataProvider: MessageDataProvider) { + builder.dataMessageBuilder .setGroupUpdateMessage(inner) .apply { profile?.let(this::setProfile) } - .build() - return Content.newBuilder() - .setDataMessage(dataMessage) - .build() } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt index fad9eba2b9..bb7057b17e 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt @@ -1,34 +1,21 @@ package org.session.libsession.messaging.messages.control -import com.google.protobuf.ByteString +import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.copyExpiration -import org.session.libsession.messaging.messages.visible.Profile import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.utilities.Log -class MessageRequestResponse(val isApproved: Boolean, var profile: Profile? = null) : ControlMessage() { +class MessageRequestResponse(val isApproved: Boolean) : ControlMessage() { override val isSelfSendValid: Boolean = true override fun shouldDiscardIfBlocked(): Boolean = true - override fun toProto(): SignalServiceProtos.Content? { - val profileProto = SignalServiceProtos.DataMessage.LokiProfile.newBuilder() - profile?.displayName?.let { profileProto.displayName = it } - profile?.profilePictureURL?.let { profileProto.profilePicture = it } - val messageRequestResponseProto = SignalServiceProtos.MessageRequestResponse.newBuilder() + override fun buildProto( + builder: SignalServiceProtos.Content.Builder, + messageDataProvider: MessageDataProvider + ) { + builder.messageRequestResponseBuilder .setIsApproved(isApproved) - .setProfile(profileProto.build()) - profile?.profileKey?.let { messageRequestResponseProto.profileKey = ByteString.copyFrom(it) } - return try { - SignalServiceProtos.Content.newBuilder() - .applyExpiryMode() - .setMessageRequestResponse(messageRequestResponseProto.build()) - .build() - } catch (e: Exception) { - Log.w(TAG, "Couldn't construct message request response proto from: $this") - null - } } companion object { @@ -37,13 +24,7 @@ class MessageRequestResponse(val isApproved: Boolean, var profile: Profile? = nu fun fromProto(proto: SignalServiceProtos.Content): MessageRequestResponse? { val messageRequestResponseProto = if (proto.hasMessageRequestResponse()) proto.messageRequestResponse else return null val isApproved = messageRequestResponseProto.isApproved - val profileProto = messageRequestResponseProto.profile - val profile = Profile().apply { - displayName = profileProto.displayName - profileKey = if (messageRequestResponseProto.hasProfileKey()) messageRequestResponseProto.profileKey.toByteArray() else null - profilePictureURL = profileProto.profilePicture - } - return MessageRequestResponse(isApproved, profile) + return MessageRequestResponse(isApproved) .copyExpiration(proto) } } diff --git a/app/src/main/java/org/session/libsession/messaging/messages/control/ReadReceipt.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/ReadReceipt.kt index 876f47dd33..4c8021e416 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/control/ReadReceipt.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/control/ReadReceipt.kt @@ -1,8 +1,8 @@ package org.session.libsession.messaging.messages.control +import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.copyExpiration import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.utilities.Log class ReadReceipt() : ControlMessage() { var timestamps: List? = null @@ -33,24 +33,15 @@ class ReadReceipt() : ControlMessage() { this.timestamps = timestamps } - override fun toProto(): SignalServiceProtos.Content? { - val timestamps = timestamps - if (timestamps == null) { - Log.w(TAG, "Couldn't construct read receipt proto from: $this") - return null - } - - return try { - SignalServiceProtos.Content.newBuilder() - .setReceiptMessage( - SignalServiceProtos.ReceiptMessage.newBuilder() - .setType(SignalServiceProtos.ReceiptMessage.Type.READ) - .addAllTimestampMs(timestamps.asIterable()).build() - ).applyExpiryMode() - .build() - } catch (e: Exception) { - Log.w(TAG, "Couldn't construct read receipt proto from: $this") - null - } + protected override fun buildProto( + builder: SignalServiceProtos.Content.Builder, + messageDataProvider: MessageDataProvider + ) { + builder + .receiptMessageBuilder + .setType(SignalServiceProtos.ReceiptMessage.Type.READ) + .addAllTimestampMs(requireNotNull(timestamps) { + "Timestamps is null" + }) } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/messages/control/TypingIndicator.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/TypingIndicator.kt index 92a172e9e6..ab2c17a1f2 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/control/TypingIndicator.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/control/TypingIndicator.kt @@ -1,8 +1,8 @@ package org.session.libsession.messaging.messages.control +import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.copyExpiration import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.utilities.Log class TypingIndicator() : ControlMessage() { var kind: Kind? = null @@ -51,21 +51,12 @@ class TypingIndicator() : ControlMessage() { this.kind = kind } - override fun toProto(): SignalServiceProtos.Content? { - val timestamp = sentTimestamp - val kind = kind - if (timestamp == null || kind == null) { - Log.w(TAG, "Couldn't construct typing indicator proto from: $this") - return null - } - return try { - SignalServiceProtos.Content.newBuilder() - .setTypingMessage(SignalServiceProtos.TypingMessage.newBuilder().setTimestampMs(timestamp).setAction(kind.toProto()).build()) - .applyExpiryMode() - .build() - } catch (e: Exception) { - Log.w(TAG, "Couldn't construct typing indicator proto from: $this") - null - } + protected override fun buildProto( + builder: SignalServiceProtos.Content.Builder, + messageDataProvider: MessageDataProvider + ) { + builder.typingMessageBuilder + .setTimestampMs(sentTimestamp!!) + .setAction(kind!!.toProto()) } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/messages/control/UnsendRequest.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/UnsendRequest.kt index 6e04375eda..38f5d7d34b 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/control/UnsendRequest.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/control/UnsendRequest.kt @@ -1,8 +1,8 @@ package org.session.libsession.messaging.messages.control +import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.copyExpiration import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.utilities.Log class UnsendRequest(var timestamp: Long? = null, var author: String? = null): ControlMessage() { @@ -24,22 +24,13 @@ class UnsendRequest(var timestamp: Long? = null, var author: String? = null): Co proto.takeIf { it.hasUnsendRequest() }?.unsendRequest?.run { UnsendRequest(timestampMs, author) }?.copyExpiration(proto) } - override fun toProto(): SignalServiceProtos.Content? { - val timestamp = timestamp - val author = author - if (timestamp == null || author == null) { - Log.w(TAG, "Couldn't construct unsend request proto from: $this") - return null - } - return try { - SignalServiceProtos.Content.newBuilder() - .setUnsendRequest(SignalServiceProtos.UnsendRequest.newBuilder().setTimestampMs(timestamp).setAuthor(author).build()) - .applyExpiryMode() - .build() - } catch (e: Exception) { - Log.w(TAG, "Couldn't construct unsend request proto from: $this") - null - } + protected override fun buildProto( + builder: SignalServiceProtos.Content.Builder, + messageDataProvider: MessageDataProvider + ) { + builder.unsendRequestBuilder + .setTimestampMs(timestamp!!) + .setAuthor(author!!) } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingEncryptedMessage.java b/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingEncryptedMessage.java deleted file mode 100644 index f5a63d4ac5..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingEncryptedMessage.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.session.libsession.messaging.messages.signal; - -public class IncomingEncryptedMessage extends IncomingTextMessage { - - public IncomingEncryptedMessage(IncomingTextMessage base, String newBody) { - super(base, newBody); - } - - @Override - public boolean isSecureMessage() { - return true; - } -} diff --git a/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingGroupMessage.java b/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingGroupMessage.java deleted file mode 100644 index 8046c82d99..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingGroupMessage.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.session.libsession.messaging.messages.signal; - -public class IncomingGroupMessage extends IncomingTextMessage { - - private final boolean updateMessage; - - public IncomingGroupMessage(IncomingTextMessage base, String body, boolean updateMessage) { - super(base, body); - this.updateMessage = updateMessage; - } - - @Override - public boolean isGroup() { - return true; - } - - public boolean isUpdateMessage() { return updateMessage; } - -} diff --git a/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java b/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java deleted file mode 100644 index 3e040232eb..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java +++ /dev/null @@ -1,168 +0,0 @@ -package org.session.libsession.messaging.messages.signal; - -import org.jspecify.annotations.Nullable; -import org.session.libsession.messaging.messages.visible.VisibleMessage; -import org.session.libsession.messaging.sending_receiving.attachments.Attachment; -import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment; -import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage; -import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview; -import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel; -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.Contact; -import org.session.libsignal.messages.SignalServiceAttachment; -import org.session.libsignal.utilities.guava.Optional; -import org.thoughtcrime.securesms.database.model.content.MessageContent; - -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; - -public class IncomingMediaMessage { - - private final Address from; - private final Address.GroupLike groupId; - private final String body; - private final boolean push; - private final long sentTimeMillis; - private final int subscriptionId; - private final long expiresIn; - private final long expireStartedAt; - private final boolean messageRequestResponse; - private final boolean hasMention; - @Nullable - private final MessageContent messageContent; - - private final DataExtractionNotificationInfoMessage dataExtractionNotification; - private final QuoteModel quote; - - private final List attachments = new LinkedList<>(); - private final List sharedContacts = new LinkedList<>(); - private final List linkPreviews = new LinkedList<>(); - - public IncomingMediaMessage(Address from, - long sentTimeMillis, - int subscriptionId, - long expiresIn, - long expireStartedAt, - boolean messageRequestResponse, - boolean hasMention, - Optional body, - Optional group, - Optional> attachments, - @Nullable MessageContent messageContent, - Optional quote, - Optional> sharedContacts, - Optional> linkPreviews, - Optional dataExtractionNotification) - { - this.messageContent = messageContent; - this.push = true; - this.from = from; - this.sentTimeMillis = sentTimeMillis; - this.body = body.orNull(); - this.subscriptionId = subscriptionId; - this.expiresIn = expiresIn; - this.expireStartedAt = expireStartedAt; - this.dataExtractionNotification = dataExtractionNotification.orNull(); - this.quote = quote.orNull(); - this.messageRequestResponse = messageRequestResponse; - this.hasMention = hasMention; - this.groupId = group.orNull(); - - this.attachments.addAll(PointerAttachment.forPointers(attachments)); - this.sharedContacts.addAll(sharedContacts.or(Collections.emptyList())); - this.linkPreviews.addAll(linkPreviews.or(Collections.emptyList())); - } - - public static IncomingMediaMessage from(VisibleMessage message, - Address from, - long expiresIn, - long expireStartedAt, - Optional group, - List attachments, - Optional quote, - Optional> linkPreviews) - { - return new IncomingMediaMessage(from, message.getSentTimestamp(), -1, expiresIn, expireStartedAt, - false, message.getHasMention(), Optional.fromNullable(message.getText()), - group, Optional.fromNullable(attachments), null, quote, Optional.absent(), linkPreviews, Optional.absent()); - } - - public int getSubscriptionId() { - return subscriptionId; - } - - public String getBody() { - return body; - } - - public List getAttachments() { - return attachments; - } - - public Address getFrom() { - return from; - } - - public Address.GroupLike getGroupId() { - return groupId; - } - - public @Nullable MessageContent getMessageContent() { - return messageContent; - } - - public boolean isPushMessage() { - return push; - } - - public long getSentTimeMillis() { - return sentTimeMillis; - } - - public long getExpiresIn() { - return expiresIn; - } - - public long getExpireStartedAt() { - return expireStartedAt; - } - - public boolean isGroupMessage() { - return groupId != null; - } - - public boolean hasMention() { - return hasMention; - } - - public boolean isScreenshotDataExtraction() { - if (dataExtractionNotification == null) return false; - else { - return dataExtractionNotification.getKind() == DataExtractionNotificationInfoMessage.Kind.SCREENSHOT; - } - } - - public boolean isMediaSavedDataExtraction() { - if (dataExtractionNotification == null) return false; - else { - return dataExtractionNotification.getKind() == DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED; - } - } - - public QuoteModel getQuote() { - return quote; - } - - public List getSharedContacts() { - return sharedContacts; - } - - public List getLinkPreviews() { - return linkPreviews; - } - - public boolean isMessageRequestResponse() { - return messageRequestResponse; - } -} diff --git a/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.kt b/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.kt new file mode 100644 index 0000000000..adf9331452 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.kt @@ -0,0 +1,57 @@ +package org.session.libsession.messaging.messages.signal + +import network.loki.messenger.libsession_util.protocol.ProFeature +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.sending_receiving.attachments.Attachment +import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage +import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview +import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel +import org.session.libsession.utilities.Address +import org.thoughtcrime.securesms.database.model.content.MessageContent + +class IncomingMediaMessage( + val from: Address, + val sentTimeMillis: Long, + val expiresIn: Long, + val expireStartedAt: Long, + val isMessageRequestResponse: Boolean, + val hasMention: Boolean, + val body: String?, + val group: Address.GroupLike?, + val attachments: List, + val proFeatures: Set, + val messageContent: MessageContent?, + val quote: QuoteModel?, + val linkPreviews: List, + val dataExtractionNotification: DataExtractionNotificationInfoMessage?, +) { + + constructor( + message: VisibleMessage, + from: Address, + expiresIn: Long, + expireStartedAt: Long, + group: Address.GroupLike?, + attachments: List, + quote: QuoteModel?, + linkPreviews: List + ): this( + from = from, + sentTimeMillis = message.sentTimestamp!!, + expiresIn = expiresIn, + expireStartedAt = expireStartedAt, + isMessageRequestResponse = false, + hasMention = message.hasMention, + body = message.text, + group = group, + attachments = attachments, + proFeatures = message.proFeatures, + messageContent = null, + quote = quote, + linkPreviews = linkPreviews, + dataExtractionNotification = null + ) + + val isMediaSavedDataExtraction: Boolean get() = + dataExtractionNotification?.kind == DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.java b/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.java deleted file mode 100644 index 73bfd7a4d4..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.java +++ /dev/null @@ -1,257 +0,0 @@ -package org.session.libsession.messaging.messages.signal; - -import android.os.Parcel; -import android.os.Parcelable; - -import androidx.annotation.Nullable; - -import org.session.libsession.messaging.calls.CallMessageType; -import org.session.libsession.messaging.messages.visible.OpenGroupInvitation; -import org.session.libsession.messaging.messages.visible.VisibleMessage; -import org.session.libsession.messaging.utilities.UpdateMessageData; -import org.session.libsession.utilities.Address; -import org.session.libsignal.utilities.guava.Optional; - -public class IncomingTextMessage implements Parcelable { - - public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { - @Override - public IncomingTextMessage createFromParcel(Parcel in) { - return new IncomingTextMessage(in); - } - - @Override - public IncomingTextMessage[] newArray(int size) { - return new IncomingTextMessage[size]; - } - }; - private static final String TAG = IncomingTextMessage.class.getSimpleName(); - - private final String message; - private Address sender; - private final int senderDeviceId; - private final int protocol; - private final String serviceCenterAddress; - private final boolean replyPathPresent; - private final String pseudoSubject; - private final long sentTimestampMillis; - private final Address.GroupLike groupId; - private final boolean push; - private final int subscriptionId; - private final long expiresInMillis; - private final long expireStartedAt; - private final boolean unidentified; - private final int callType; - private final boolean hasMention; - - private boolean isOpenGroupInvitation = false; - - public IncomingTextMessage(Address sender, int senderDeviceId, long sentTimestampMillis, - String encodedBody, Optional group, - long expiresInMillis, long expireStartedAt, boolean unidentified, boolean hasMention) { - this(sender, senderDeviceId, sentTimestampMillis, encodedBody, group, expiresInMillis, expireStartedAt, unidentified, -1, hasMention); - } - - public IncomingTextMessage(Address sender, int senderDeviceId, long sentTimestampMillis, - String encodedBody, Optional group, - long expiresInMillis, long expireStartedAt, boolean unidentified, int callType, boolean hasMention) { - this(sender, senderDeviceId, sentTimestampMillis, encodedBody, group, expiresInMillis, expireStartedAt, unidentified, callType, hasMention, true); - } - - public IncomingTextMessage(Address sender, int senderDeviceId, long sentTimestampMillis, - String encodedBody, Optional group, - long expiresInMillis, long expireStartedAt, boolean unidentified, int callType, boolean hasMention, boolean isPush) { - this.message = encodedBody; - this.sender = sender; - this.senderDeviceId = senderDeviceId; - this.protocol = 31337; - this.serviceCenterAddress = "GCM"; - this.replyPathPresent = true; - this.pseudoSubject = ""; - this.sentTimestampMillis = sentTimestampMillis; - this.push = isPush; - this.subscriptionId = -1; - this.expiresInMillis = expiresInMillis; - this.expireStartedAt = expireStartedAt; - this.unidentified = unidentified; - this.callType = callType; - this.hasMention = hasMention; - this.groupId = group.orNull(); - } - - public IncomingTextMessage(Parcel in) { - this.message = in.readString(); - this.sender = in.readParcelable(IncomingTextMessage.class.getClassLoader()); - this.senderDeviceId = in.readInt(); - this.protocol = in.readInt(); - this.serviceCenterAddress = in.readString(); - this.replyPathPresent = (in.readInt() == 1); - this.pseudoSubject = in.readString(); - this.sentTimestampMillis = in.readLong(); - this.groupId = in.readParcelable(IncomingTextMessage.class.getClassLoader()); - this.push = (in.readInt() == 1); - this.subscriptionId = in.readInt(); - this.expiresInMillis = in.readLong(); - this.expireStartedAt = in.readLong(); - this.unidentified = in.readInt() == 1; - this.isOpenGroupInvitation = in.readInt() == 1; - this.callType = in.readInt(); - this.hasMention = in.readInt() == 1; - } - - public IncomingTextMessage(IncomingTextMessage base, String newBody) { - this.message = newBody; - this.sender = base.getSender(); - this.senderDeviceId = base.getSenderDeviceId(); - this.protocol = base.getProtocol(); - this.serviceCenterAddress = base.getServiceCenterAddress(); - this.replyPathPresent = base.isReplyPathPresent(); - this.pseudoSubject = base.getPseudoSubject(); - this.sentTimestampMillis = base.getSentTimestampMillis(); - this.groupId = base.getGroupId(); - this.push = base.isPush(); - this.subscriptionId = base.getSubscriptionId(); - this.expiresInMillis = base.getExpiresIn(); - this.expireStartedAt = base.getExpireStartedAt(); - this.unidentified = base.isUnidentified(); - this.isOpenGroupInvitation = base.isOpenGroupInvitation(); - this.callType = base.callType; - this.hasMention = base.hasMention; - } - - public static IncomingTextMessage from(VisibleMessage message, - Address sender, - Optional group, - long expiresInMillis, - long expireStartedAt) - { - return new IncomingTextMessage(sender, 1, message.getSentTimestamp(), message.getText(), group, expiresInMillis, expireStartedAt, false, message.getHasMention()); - } - - public static IncomingTextMessage fromOpenGroupInvitation(OpenGroupInvitation openGroupInvitation, - Address sender, - Long sentTimestamp, - long expiresInMillis, - long expireStartedAt) { - String url = openGroupInvitation.getUrl(); - String name = openGroupInvitation.getName(); - if (url == null || name == null) { return null; } - // FIXME: Doing toJSON() to get the body here is weird - String body = UpdateMessageData.Companion.buildOpenGroupInvitation(url, name).toJSON(); - IncomingTextMessage incomingTextMessage = new IncomingTextMessage(sender, 1, sentTimestamp, body, Optional.absent(), expiresInMillis, expireStartedAt, false, false); - incomingTextMessage.isOpenGroupInvitation = true; - return incomingTextMessage; - } - - public static IncomingTextMessage fromCallInfo(CallMessageType callMessageType, - Address sender, - Optional group, - long sentTimestamp, - long expiresInMillis, - long expireStartedAt) { - return new IncomingTextMessage(sender, 1, sentTimestamp, null, group, expiresInMillis, expireStartedAt, false, callMessageType.ordinal(), false, false); - } - - public int getSubscriptionId() { - return subscriptionId; - } - - public long getExpiresIn() { - return expiresInMillis; - } - - public long getExpireStartedAt() { - return expireStartedAt; - } - - public long getSentTimestampMillis() { - return sentTimestampMillis; - } - - public String getPseudoSubject() { - return pseudoSubject; - } - - public String getMessageBody() { - return message; - } - - public Address getSender() { - return sender; - } - - public int getSenderDeviceId() { - return senderDeviceId; - } - - public int getProtocol() { - return protocol; - } - - public String getServiceCenterAddress() { - return serviceCenterAddress; - } - - public boolean isReplyPathPresent() { - return replyPathPresent; - } - - public boolean isSecureMessage() { - return false; - } - - public boolean isPush() { - return push; - } - - public @Nullable Address.GroupLike getGroupId() { - return groupId; - } - - public boolean isGroup() { - return false; - } - - public boolean isUnidentified() { - return unidentified; - } - - public boolean isOpenGroupInvitation() { return isOpenGroupInvitation; } - - public boolean hasMention() { return hasMention; } - - public boolean isUnreadCallMessage() { - return callType == CallMessageType.CALL_MISSED.ordinal() || callType == CallMessageType.CALL_FIRST_MISSED.ordinal(); - } - - @Nullable - public CallMessageType getCallType() { - int callTypeLength = CallMessageType.values().length; - if (callType < 0 || callType >= callTypeLength) return null; - return CallMessageType.values()[callType]; - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel out, int flags) { - out.writeString(message); - out.writeParcelable(sender, flags); - out.writeInt(senderDeviceId); - out.writeInt(protocol); - out.writeString(serviceCenterAddress); - out.writeInt(replyPathPresent ? 1 : 0); - out.writeString(pseudoSubject); - out.writeLong(sentTimestampMillis); - out.writeParcelable(groupId, flags); - out.writeInt(push ? 1 : 0); - out.writeInt(subscriptionId); - out.writeInt(unidentified ? 1 : 0); - out.writeInt(isOpenGroupInvitation ? 1 : 0); - out.writeInt(callType); - out.writeInt(hasMention ? 1 : 0); - } -} diff --git a/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.kt b/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.kt new file mode 100644 index 0000000000..3d3be4641e --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.kt @@ -0,0 +1,113 @@ +package org.session.libsession.messaging.messages.signal + +import network.loki.messenger.libsession_util.protocol.ProFeature +import org.session.libsession.messaging.calls.CallMessageType +import org.session.libsession.messaging.messages.visible.OpenGroupInvitation +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.utilities.UpdateMessageData +import org.session.libsession.utilities.Address +import java.util.EnumSet + +data class IncomingTextMessage( + val message: String?, + val sender: Address, + val sentTimestampMillis: Long, + val group: Address.GroupLike?, + val push: Boolean, + val expiresInMillis: Long, + val expireStartedAt: Long, + val callType: Int, + val hasMention: Boolean, + val isOpenGroupInvitation: Boolean, + val isSecureMessage: Boolean, + val proFeatures: Set, + val isGroupMessage: Boolean = false, + val isGroupUpdateMessage: Boolean = false, +) { + val callMessageType: CallMessageType? get() = + CallMessageType.entries.getOrNull(callType) + + val isUnreadCallMessage: Boolean + get() = callMessageType in EnumSet.of( + CallMessageType.CALL_MISSED, + CallMessageType.CALL_FIRST_MISSED, + ) + + init { + check(!isGroupUpdateMessage || isGroupMessage) { + "A message cannot be a group update message if it is not a group message" + } + } + + constructor( + message: VisibleMessage, + sender: Address, + group: Address.GroupLike?, + expiresInMillis: Long, + expireStartedAt: Long, + ): this( + message = message.text, + sender = sender, + sentTimestampMillis = message.sentTimestamp!!, + group = group, + push = true, + expiresInMillis = expiresInMillis, + expireStartedAt = expireStartedAt, + callType = -1, + hasMention = message.hasMention, + isOpenGroupInvitation = false, + isSecureMessage = false, + proFeatures = message.proFeatures, + ) + constructor( + callMessageType: CallMessageType, + sender: Address, + group: Address.GroupLike?, + sentTimestampMillis: Long, + expiresInMillis: Long, + expireStartedAt: Long, + ): this( + message = null, + sender = sender, + sentTimestampMillis = sentTimestampMillis, + group = group, + push = false, + expiresInMillis = expiresInMillis, + expireStartedAt = expireStartedAt, + callType = callMessageType.ordinal, + hasMention = false, + isOpenGroupInvitation = false, + isSecureMessage = false, + proFeatures = emptySet(), + ) + + companion object { + fun fromOpenGroupInvitation( + invitation: OpenGroupInvitation, + sender: Address, + sentTimestampMillis: Long, + expiresInMillis: Long, + expireStartedAt: Long, + ): IncomingTextMessage? { + val body = UpdateMessageData.buildOpenGroupInvitation( + url = invitation.url ?: return null, + name = invitation.name ?: return null, + ).toJSON() + + return IncomingTextMessage( + message = body, + sender = sender, + sentTimestampMillis = sentTimestampMillis, + group = null, + push = true, + expiresInMillis = expiresInMillis, + expireStartedAt = expireStartedAt, + callType = -1, + hasMention = false, + isOpenGroupInvitation = true, + isSecureMessage = false, + proFeatures = emptySet(), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingGroupMediaMessage.java b/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingGroupMediaMessage.java deleted file mode 100644 index 05ed23dbaf..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingGroupMediaMessage.java +++ /dev/null @@ -1,56 +0,0 @@ -package org.session.libsession.messaging.messages.signal; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.DistributionTypes; -import org.session.libsession.messaging.sending_receiving.attachments.Attachment; -import org.session.libsession.utilities.Contact; -import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview; -import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel; -import org.thoughtcrime.securesms.database.model.content.MessageContent; - -import java.util.LinkedList; -import java.util.List; - -public class OutgoingGroupMediaMessage extends OutgoingSecureMediaMessage { - - private final String groupID; - private final boolean isUpdateMessage; - - public OutgoingGroupMediaMessage(@NonNull Address recipient, - @NonNull String body, - @Nullable String groupId, - @Nullable final Attachment avatar, - long sentTime, - long expireIn, - long expireStartedAt, - boolean updateMessage, - @Nullable QuoteModel quote, - @NonNull List contacts, - @NonNull List previews, - @Nullable MessageContent messageContent) - { - super(recipient, body, - new LinkedList() {{if (avatar != null) add(avatar);}}, - sentTime, - DistributionTypes.CONVERSATION, expireIn, expireStartedAt, quote, contacts, previews, messageContent); - - this.groupID = groupId; - this.isUpdateMessage = updateMessage; - } - - @Override - public boolean isGroup() { - return true; - } - - public String getGroupId() { - return groupID; - } - - public boolean isUpdateMessage() { - return isUpdateMessage; - } -} diff --git a/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.java b/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.java deleted file mode 100644 index 67c35f699c..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.java +++ /dev/null @@ -1,163 +0,0 @@ -package org.session.libsession.messaging.messages.signal; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.session.libsession.messaging.messages.visible.VisibleMessage; -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.utilities.Address; -import org.session.libsession.utilities.Contact; -import org.session.libsession.utilities.DistributionTypes; -import org.session.libsession.utilities.IdentityKeyMismatch; -import org.session.libsession.utilities.NetworkFailure; -import org.thoughtcrime.securesms.database.model.content.MessageContent; - -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; - -/** - * Represents an outgoing mms message. Note this class is only used for saving messages - * into the database. We will still use {@link org.session.libsession.messaging.messages.Message} - * as a model when sending the message to the network. - *
- * See {@link OutgoingTextMessage} for the sms table counterpart. - */ -public class OutgoingMediaMessage { - - private final Address recipient; - protected final String body; - protected final List attachments; - private final long sentTimeMillis; - private final int distributionType; - private final int subscriptionId; - private final long expiresIn; - private final long expireStartedAt; - private final QuoteModel outgoingQuote; - @Nullable - private final MessageContent messageContent; - - private final List networkFailures = new LinkedList<>(); - private final List identityKeyMismatches = new LinkedList<>(); - private final List contacts = new LinkedList<>(); - private final List linkPreviews = new LinkedList<>(); - - public OutgoingMediaMessage(Address recipient, String message, - List attachments, long sentTimeMillis, - int subscriptionId, long expiresIn, long expireStartedAt, - int distributionType, - @Nullable QuoteModel outgoingQuote, - @NonNull List contacts, - @NonNull List linkPreviews, - @NonNull List networkFailures, - @NonNull List identityKeyMismatches, - @Nullable MessageContent messageContent) - { - this.recipient = recipient; - this.body = message; - this.sentTimeMillis = sentTimeMillis; - this.distributionType = distributionType; - this.attachments = attachments; - this.subscriptionId = subscriptionId; - this.expiresIn = expiresIn; - this.expireStartedAt = expireStartedAt; - this.outgoingQuote = outgoingQuote; - this.messageContent = messageContent; - - this.contacts.addAll(contacts); - this.linkPreviews.addAll(linkPreviews); - this.networkFailures.addAll(networkFailures); - this.identityKeyMismatches.addAll(identityKeyMismatches); - } - - public OutgoingMediaMessage(OutgoingMediaMessage that) { - this.recipient = that.getRecipient(); - this.body = that.body; - this.distributionType = that.distributionType; - this.attachments = that.attachments; - this.sentTimeMillis = that.sentTimeMillis; - this.subscriptionId = that.subscriptionId; - this.expiresIn = that.expiresIn; - this.expireStartedAt = that.expireStartedAt; - this.outgoingQuote = that.outgoingQuote; - this.messageContent = that.messageContent; - - this.identityKeyMismatches.addAll(that.identityKeyMismatches); - this.networkFailures.addAll(that.networkFailures); - this.contacts.addAll(that.contacts); - this.linkPreviews.addAll(that.linkPreviews); - } - - public static OutgoingMediaMessage from(VisibleMessage message, - Address recipient, - List attachments, - @Nullable QuoteModel outgoingQuote, - @Nullable LinkPreview linkPreview, - long expiresInMillis, - long expireStartedAt) - { - List previews = Collections.emptyList(); - if (linkPreview != null) { - previews = Collections.singletonList(linkPreview); - } - return new OutgoingMediaMessage(recipient, message.getText(), attachments, message.getSentTimestamp(), -1, - expiresInMillis, expireStartedAt, DistributionTypes.DEFAULT, outgoingQuote, - Collections.emptyList(), previews, Collections.emptyList(), Collections.emptyList(), null); - } - - @Nullable - public MessageContent getMessageContent() { - return messageContent; - } - - public Address getRecipient() { - return recipient; - } - - public String getBody() { - return body; - } - - public List getAttachments() { - return attachments; - } - - public boolean isSecure() { - return true; - } - - public boolean isGroup() { - return false; - } - - public long getSentTimeMillis() { - return sentTimeMillis; - } - - public int getSubscriptionId() { - return subscriptionId; - } - - public long getExpiresIn() { - return expiresIn; - } - - public long getExpireStartedAt() { - return expireStartedAt; - } - - public @Nullable QuoteModel getOutgoingQuote() { - return outgoingQuote; - } - - public @NonNull List getSharedContacts() { - return contacts; - } - - public @NonNull List getLinkPreviews() { - return linkPreviews; - } - -} diff --git a/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.kt b/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.kt new file mode 100644 index 0000000000..b7183cd8c8 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.kt @@ -0,0 +1,83 @@ +package org.session.libsession.messaging.messages.signal + +import network.loki.messenger.libsession_util.protocol.ProFeature +import org.session.libsession.messaging.messages.visible.VisibleMessage +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.utilities.Address +import org.thoughtcrime.securesms.database.model.content.MessageContent + +class OutgoingMediaMessage( + val recipient: Address, + val body: String?, + val attachments: List, + val sentTimeMillis: Long, + val expiresInMillis: Long, + val expireStartedAtMillis: Long, + val outgoingQuote: QuoteModel?, + val messageContent: MessageContent?, + val linkPreviews: List, + val group: Address.GroupLike?, + val isGroupUpdateMessage: Boolean, + val proFeatures: Set = emptySet() +) { + init { + check(!isGroupUpdateMessage || group != null) { + "Group update messages must have a group address" + } + } + + constructor( + message: VisibleMessage, + recipient: Address, + attachments: List, + outgoingQuote: QuoteModel?, + linkPreview: LinkPreview?, + expiresInMillis: Long, + expireStartedAt: Long + ) : this( + recipient = recipient, + body = message.text, + attachments = attachments, + sentTimeMillis = message.sentTimestamp!!, + expiresInMillis = expiresInMillis, + expireStartedAtMillis = expireStartedAt, + outgoingQuote = outgoingQuote, + messageContent = null, + linkPreviews = linkPreview?.let { listOf(it) } ?: emptyList(), + group = null, + isGroupUpdateMessage = false, + ) + + constructor( + recipient: Address, + body: String?, + group: Address.GroupLike, + avatar: Attachment?, + sentTimeMillis: Long, + expiresInMillis: Long, + expireStartedAtMillis: Long, + isGroupUpdateMessage: Boolean, + quote: QuoteModel?, + previews: List, + messageContent: MessageContent?, + ) : this( + recipient = recipient, + body = body, + attachments = avatar?.let { listOf(it) } ?: emptyList(), + sentTimeMillis = sentTimeMillis, + expiresInMillis = expiresInMillis, + expireStartedAtMillis = expireStartedAtMillis, + outgoingQuote = quote, + messageContent = messageContent, + linkPreviews = previews, + group = group, + isGroupUpdateMessage = isGroupUpdateMessage, + ) + + // legacy code + val isSecure: Boolean get() = true + + val isGroup: Boolean get() = group != null +} diff --git a/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingSecureMediaMessage.java b/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingSecureMediaMessage.java deleted file mode 100644 index 0f594d4eba..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingSecureMediaMessage.java +++ /dev/null @@ -1,40 +0,0 @@ -package org.session.libsession.messaging.messages.signal; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.session.libsession.messaging.sending_receiving.attachments.Attachment; -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.Contact; -import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview; -import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel; -import org.thoughtcrime.securesms.database.model.content.MessageContent; - -import java.util.Collections; -import java.util.List; - -public class OutgoingSecureMediaMessage extends OutgoingMediaMessage { - - public OutgoingSecureMediaMessage(Address recipient, String body, - List attachments, - long sentTimeMillis, - int distributionType, - long expiresIn, - long expireStartedAt, - @Nullable QuoteModel quote, - @NonNull List contacts, - @NonNull List previews, - @Nullable MessageContent messageContent) - { - super(recipient, body, attachments, sentTimeMillis, -1, expiresIn, expireStartedAt, distributionType, quote, contacts, previews, Collections.emptyList(), Collections.emptyList(), messageContent); - } - - public OutgoingSecureMediaMessage(OutgoingMediaMessage base) { - super(base); - } - - @Override - public boolean isSecure() { - return true; - } -} diff --git a/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.java b/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.java deleted file mode 100644 index c79a68102f..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.java +++ /dev/null @@ -1,70 +0,0 @@ -package org.session.libsession.messaging.messages.signal; - -import org.session.libsession.messaging.messages.visible.OpenGroupInvitation; -import org.session.libsession.messaging.messages.visible.VisibleMessage; -import org.session.libsession.utilities.Address; -import org.session.libsession.messaging.utilities.UpdateMessageData; - -public class OutgoingTextMessage { - private final Address recipient; - private final String message; - private final int subscriptionId; - private final long expiresIn; - private final long expireStartedAt; - private final long sentTimestampMillis; - private boolean isOpenGroupInvitation = false; - - public OutgoingTextMessage(Address recipient, String message, long expiresIn, long expireStartedAt, int subscriptionId, long sentTimestampMillis) { - this.recipient = recipient; - this.message = message; - this.expiresIn = expiresIn; - this.expireStartedAt= expireStartedAt; - this.subscriptionId = subscriptionId; - this.sentTimestampMillis = sentTimestampMillis; - } - - public static OutgoingTextMessage from(VisibleMessage message, Address recipient, long expiresInMillis, long expireStartedAt) { - return new OutgoingTextMessage(recipient, message.getText(), expiresInMillis, expireStartedAt, -1, message.getSentTimestamp()); - } - - public static OutgoingTextMessage fromOpenGroupInvitation(OpenGroupInvitation openGroupInvitation, Address recipient, Long sentTimestamp, long expiresInMillis, long expireStartedAt) { - String url = openGroupInvitation.getUrl(); - String name = openGroupInvitation.getName(); - if (url == null || name == null) { return null; } - // FIXME: Doing toJSON() to get the body here is weird - String body = UpdateMessageData.Companion.buildOpenGroupInvitation(url, name).toJSON(); - OutgoingTextMessage outgoingTextMessage = new OutgoingTextMessage(recipient, body, expiresInMillis, expireStartedAt, -1, sentTimestamp); - outgoingTextMessage.isOpenGroupInvitation = true; - return outgoingTextMessage; - } - - public long getExpiresIn() { - return expiresIn; - } - - public long getExpireStartedAt() { - return expireStartedAt; - } - - public int getSubscriptionId() { - return subscriptionId; - } - - public String getMessageBody() { - return message; - } - - public Address getRecipient() { - return recipient; - } - - public long getSentTimestampMillis() { - return sentTimestampMillis; - } - - public boolean isSecureMessage() { - return true; - } - - public boolean isOpenGroupInvitation() { return isOpenGroupInvitation; } -} diff --git a/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.kt b/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.kt new file mode 100644 index 0000000000..912da3d5c7 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.kt @@ -0,0 +1,53 @@ +package org.session.libsession.messaging.messages.signal + +import network.loki.messenger.libsession_util.protocol.ProFeature +import org.session.libsession.messaging.messages.visible.OpenGroupInvitation +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.utilities.UpdateMessageData +import org.session.libsession.utilities.Address + +data class OutgoingTextMessage( + val recipient: Address, + val message: String?, + val expiresInMillis: Long, + val expireStartedAtMillis: Long, + val sentTimestampMillis: Long, + val isOpenGroupInvitation: Boolean, + val proFeatures: Set = emptySet() +) { + constructor( + message: VisibleMessage, + recipient: Address, + expiresInMillis: Long, + expireStartedAtMillis: Long, + ): this( + recipient = recipient, + message = message.text, + expiresInMillis = expiresInMillis, + expireStartedAtMillis = expireStartedAtMillis, + sentTimestampMillis = message.sentTimestamp!!, + isOpenGroupInvitation = false, + ) + + companion object { + fun fromOpenGroupInvitation( + invitation: OpenGroupInvitation, + recipient: Address, + sentTimestampMillis: Long, + expiresInMillis: Long, + expireStartedAtMillis: Long, + ): OutgoingTextMessage? { + return OutgoingTextMessage( + recipient = recipient, + message = UpdateMessageData.buildOpenGroupInvitation( + url = invitation.url ?: return null, + name = invitation.name ?: return null, + ).toJSON(), + expiresInMillis = expiresInMillis, + expireStartedAtMillis = expireStartedAtMillis, + sentTimestampMillis = sentTimestampMillis, + isOpenGroupInvitation = true, + ) + } + } +} diff --git a/app/src/main/java/org/session/libsession/messaging/messages/visible/Profile.kt b/app/src/main/java/org/session/libsession/messaging/messages/visible/Profile.kt deleted file mode 100644 index b5677282cb..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/messages/visible/Profile.kt +++ /dev/null @@ -1,63 +0,0 @@ -package org.session.libsession.messaging.messages.visible - -import com.google.protobuf.ByteString -import org.session.libsignal.utilities.Log -import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile -import org.thoughtcrime.securesms.util.DateUtils.Companion.asEpochMillis -import org.thoughtcrime.securesms.util.DateUtils.Companion.asEpochSeconds -import org.thoughtcrime.securesms.util.DateUtils.Companion.millsToInstant -import org.thoughtcrime.securesms.util.DateUtils.Companion.secondsToInstant -import org.thoughtcrime.securesms.util.DateUtils.Companion.toEpochSeconds -import java.time.Instant -import java.time.ZonedDateTime - -class Profile( - var displayName: String? = null, - var profileKey: ByteArray? = null, - var profilePictureURL: String? = null, - var profileUpdated: Instant? = null -) { - - companion object { - const val TAG = "Profile" - - fun fromProto(proto: SignalServiceProtos.DataMessage): Profile? { - val profileProto = proto.profile ?: return null - val displayName = profileProto.displayName ?: return null - val profileKey = proto.profileKey - val profilePictureURL = profileProto.profilePicture - val profileUpdated = profileProto.lastProfileUpdateSeconds - .takeIf { profileProto.hasLastProfileUpdateSeconds() } - ?.secondsToInstant() - - if (profileKey != null && profilePictureURL != null) { - return Profile(displayName, profileKey.toByteArray(), profilePictureURL, profileUpdated = profileUpdated) - } else { - return Profile(displayName, profileUpdated = profileUpdated) - } - } - } - - fun toProto(): SignalServiceProtos.DataMessage? { - val displayName = displayName - if (displayName == null) { - Log.w(TAG, "Couldn't construct profile proto from: $this") - return null - } - val dataMessageProto = SignalServiceProtos.DataMessage.newBuilder() - val profileProto = SignalServiceProtos.DataMessage.LokiProfile.newBuilder() - profileProto.displayName = displayName - profileKey?.let { dataMessageProto.profileKey = ByteString.copyFrom(it) } - profilePictureURL?.let { profileProto.profilePicture = it } - profileUpdated?.let { profileProto.lastProfileUpdateSeconds = it.toEpochSeconds() } - // Build - try { - dataMessageProto.profile = profileProto.build() - return dataMessageProto.build() - } catch (e: Exception) { - Log.w(TAG, "Couldn't construct profile proto from: $this") - return null - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt b/app/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt index 42059c1c50..410d244885 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt @@ -1,12 +1,18 @@ package org.session.libsession.messaging.messages.visible +import androidx.annotation.Keep import network.loki.messenger.BuildConfig -import org.session.libsession.messaging.MessagingModuleConfiguration +import network.loki.messenger.libsession_util.protocol.ProFeature +import network.loki.messenger.libsession_util.protocol.ProMessageFeature +import network.loki.messenger.libsession_util.protocol.ProProfileFeature +import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.copyExpiration import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.pro.toProMessageBitSetValue +import org.thoughtcrime.securesms.pro.toProProfileBitSetValue import org.session.libsession.messaging.sending_receiving.attachments.Attachment as SignalAttachment /** @@ -20,13 +26,17 @@ data class VisibleMessage( val attachmentIDs: MutableList = mutableListOf(), var quote: Quote? = null, var linkPreview: LinkPreview? = null, - var profile: Profile? = null, var openGroupInvitation: OpenGroupInvitation? = null, var reaction: Reaction? = null, var hasMention: Boolean = false, - var blocksMessageRequests: Boolean = false + var blocksMessageRequests: Boolean = false, + var proFeatures: Set = emptySet() ) : Message() { + // This empty constructor is needed for kryo serialization + @Keep + constructor(): this(proFeatures = emptySet()) + override val isSelfSendValid: Boolean = true override fun shouldDiscardIfBlocked(): Boolean = true @@ -54,18 +64,19 @@ data class VisibleMessage( if (it.hasQuote()) quote = Quote.fromProto(it.quote) linkPreview = it.previewList.firstOrNull()?.let(LinkPreview::fromProto) if (it.hasOpenGroupInvitation()) openGroupInvitation = it.openGroupInvitation?.let(OpenGroupInvitation::fromProto) - // TODO Contact - profile = Profile.fromProto(it) if (it.hasReaction()) reaction = it.reaction?.let(Reaction::fromProto) blocksMessageRequests = it.hasBlocksCommunityMessageRequests() && it.blocksCommunityMessageRequests }.copyExpiration(proto) } } - override fun toProto(): SignalServiceProtos.Content? { - val proto = SignalServiceProtos.Content.newBuilder() - // Profile - val dataMessage = profile?.toProto()?.toBuilder() ?: SignalServiceProtos.DataMessage.newBuilder() + protected override fun buildProto( + builder: SignalServiceProtos.Content.Builder, + messageDataProvider: MessageDataProvider + ) { + val dataMessage = builder.dataMessageBuilder + + // Text if (text != null) { dataMessage.body = text } // Quote @@ -89,8 +100,7 @@ data class VisibleMessage( dataMessage.openGroupInvitation = openGroupInvitationProto } // Attachments - val database = MessagingModuleConfiguration.shared.messageDataProvider - val attachments = attachmentIDs.mapNotNull { database.getSignalAttachmentPointer(it) } + val attachments = attachmentIDs.mapNotNull { messageDataProvider.getSignalAttachmentPointer(it) } if (attachments.any { it.url.isNullOrEmpty() }) { if (BuildConfig.DEBUG) { Log.w(TAG, "Sending a message before all associated attachments have been uploaded.") @@ -98,9 +108,6 @@ data class VisibleMessage( } val pointers = attachments.mapNotNull { Attachment.createAttachmentPointer(it) } dataMessage.addAllAttachments(pointers) - // TODO: Contact - // Expiration timer on the message - proto.applyExpiryMode() // Community blocked message requests flag dataMessage.blocksCommunityMessageRequests = blocksMessageRequests @@ -108,13 +115,18 @@ data class VisibleMessage( if (syncTarget != null) { dataMessage.syncTarget = syncTarget } - // Build - return try { - proto.dataMessage = dataMessage.build() - proto.build() - } catch (e: Exception) { - Log.w(TAG, "Couldn't construct visible message proto from: $this") - null + + // Pro features + if (proFeatures.any { it is ProMessageFeature }) { + builder.proMessageBuilder.setMsgBitset( + proFeatures.toProMessageBitSetValue() + ) + } + + if (proFeatures.any { it is ProProfileFeature }) { + builder.proMessageBuilder.setProfileBitset( + proFeatures.toProProfileBitSetValue() + ) } } // endregion diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt index 625061bf0e..93fac63e91 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt @@ -62,7 +62,7 @@ data class OpenGroupMessage( }.getOrNull() ?: return null } else { - val x25519PublicKey = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair().publicKey.serialize() + val x25519PublicKey = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair().pubKey.data if (sender != x25519PublicKey.toHexString() && !userEdKeyPair.pubKey.data.toHexString().equals(sender?.removingIdPrefixIfNeeded(), true)) return null try { ED25519.sign( diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt new file mode 100644 index 0000000000..a16f8d24e8 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt @@ -0,0 +1,217 @@ +package org.session.libsession.messaging.sending_receiving + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import network.loki.messenger.libsession_util.ED25519 +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.messaging.messages.ProfileUpdateHandler +import org.session.libsession.messaging.messages.control.GroupUpdated +import org.session.libsession.messaging.utilities.MessageAuthentication.buildDeleteMemberContentSignature +import org.session.libsession.messaging.utilities.MessageAuthentication.buildGroupInviteSignature +import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeSignature +import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.ManagerScope +import java.security.SignatureException +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class GroupMessageHandler @Inject constructor( + private val profileUpdateHandler: ProfileUpdateHandler, + private val storage: StorageProtocol, + private val groupManagerV2: GroupManagerV2, + @param:ManagerScope private val scope: CoroutineScope, +) { + fun handleGroupUpdated(message: GroupUpdated, groupId: AccountId?, proto: SignalServiceProtos.Content) { + val inner = message.inner + if (groupId == null && + !inner.hasInviteMessage() && !inner.hasPromoteMessage()) { + throw NullPointerException("Message wasn't polled from a closed group!") + } + + // Update profile if needed + ProfileUpdateHandler.Updates.create(proto)?.let { updates -> + profileUpdateHandler.handleProfileUpdate( + senderId = AccountId(message.sender!!), + updates = updates, + fromCommunity = null // Groupv2 is not a community + ) + } + + when { + inner.hasInviteMessage() -> handleNewLibSessionClosedGroupMessage(message, proto) + inner.hasInviteResponse() -> handleInviteResponse(message, groupId!!) + inner.hasPromoteMessage() -> handlePromotionMessage(message, proto) + inner.hasInfoChangeMessage() -> handleGroupInfoChange(message, groupId!!) + inner.hasMemberChangeMessage() -> handleMemberChange(message, groupId!!) + inner.hasMemberLeftMessage() -> handleMemberLeft(message, groupId!!) + inner.hasMemberLeftNotificationMessage() -> handleMemberLeftNotification(message, groupId!!) + inner.hasDeleteMemberContent() -> handleDeleteMemberContent(message, groupId!!) + } + } + + private fun handleNewLibSessionClosedGroupMessage(message: GroupUpdated, proto: SignalServiceProtos.Content) { + val storage = storage + val ourUserId = storage.getUserPublicKey()!! + val invite = message.inner.inviteMessage + val groupId = AccountId(invite.groupSessionId) + verifyAdminSignature( + groupSessionId = groupId, + signatureData = invite.adminSignature.toByteArray(), + messageToValidate = buildGroupInviteSignature(AccountId(ourUserId), message.sentTimestamp!!) + ) + + val sender = message.sender!! + val adminId = AccountId(sender) + scope.launch { + try { + groupManagerV2 + .handleInvitation( + groupId = groupId, + groupName = invite.name, + authData = invite.memberAuthData.toByteArray(), + inviter = adminId, + inviterName = if (proto.hasDataMessage() && proto.dataMessage.hasProfile() && proto.dataMessage.profile.hasDisplayName()) + proto.dataMessage.profile.displayName + else null, + inviteMessageHash = message.serverHash!!, + inviteMessageTimestamp = message.sentTimestamp!!, + ) + } catch (e: Exception) { + Log.e("GroupUpdated", "Failed to handle invite message", e) + } + } + } + + /** + * Does nothing on successful signature verification, throws otherwise. + * Assumes the signer is using the ed25519 group key signing key + * @param groupSessionId the AccountId of the group to check the signature against + * @param signatureData the byte array supplied to us through a protobuf message from the admin + * @param messageToValidate the expected values used for this signature generation, often something like `INVITE||{inviteeSessionId}||{timestamp}` + * @throws SignatureException if signature cannot be verified with given parameters + */ + private fun verifyAdminSignature(groupSessionId: AccountId, signatureData: ByteArray, messageToValidate: ByteArray) { + val groupPubKey = groupSessionId.pubKeyBytes + if (!ED25519.verify(signature = signatureData, ed25519PublicKey = groupPubKey, message = messageToValidate)) { + throw SignatureException("Verification failed for signature data") + } + } + + private fun handleInviteResponse(message: GroupUpdated, closedGroup: AccountId) { + val sender = message.sender!! + // val profile = message // maybe we do need data to be the inner so we can access profile + val approved = message.inner.inviteResponse.isApproved + scope.launch { + try { + groupManagerV2.handleInviteResponse(closedGroup, AccountId(sender), approved) + } catch (e: Exception) { + Log.e("GroupUpdated", "Failed to handle invite response", e) + } + } + } + + + private fun handlePromotionMessage(message: GroupUpdated, proto: SignalServiceProtos.Content) { + val promotion = message.inner.promoteMessage + val seed = promotion.groupIdentitySeed.toByteArray() + val sender = message.sender!! + val adminId = AccountId(sender) + scope.launch { + try { + groupManagerV2 + .handlePromotion( + groupId = AccountId(IdPrefix.GROUP, ED25519.generate(seed).pubKey.data), + groupName = promotion.name, + adminKeySeed = seed, + promoter = adminId, + promoterName = if (proto.hasDataMessage() && proto.dataMessage.hasProfile() && proto.dataMessage.profile.hasDisplayName()) + proto.dataMessage.profile.displayName + else null, + promoteMessageHash = message.serverHash!!, + promoteMessageTimestamp = message.sentTimestamp!!, + ) + } catch (e: Exception) { + Log.e("GroupUpdated", "Failed to handle promotion message", e) + } + } + } + + private fun handleGroupInfoChange(message: GroupUpdated, closedGroup: AccountId) { + val inner = message.inner + val infoChanged = inner.infoChangeMessage ?: return + if (!infoChanged.hasAdminSignature()) return Log.e("GroupUpdated", "Info changed message doesn't contain admin signature") + val adminSignature = infoChanged.adminSignature + val type = infoChanged.type + val timestamp = message.sentTimestamp!! + verifyAdminSignature(closedGroup, adminSignature.toByteArray(), buildInfoChangeSignature(type, timestamp)) + + groupManagerV2.handleGroupInfoChange(message, closedGroup) + } + + + private fun handleMemberChange(message: GroupUpdated, closedGroup: AccountId) { + val memberChange = message.inner.memberChangeMessage + val type = memberChange.type + val timestamp = message.sentTimestamp!! + verifyAdminSignature(closedGroup, + memberChange.adminSignature.toByteArray(), + buildMemberChangeSignature(type, timestamp) + ) + storage.insertGroupInfoChange(message, closedGroup) + } + + private fun handleMemberLeft(message: GroupUpdated, closedGroup: AccountId) { + scope.launch { + try { + groupManagerV2.handleMemberLeftMessage( + AccountId(message.sender!!), closedGroup + ) + } catch (e: Exception) { + Log.e("GroupUpdated", "Failed to handle member left message", e) + } + } + } + + private fun handleMemberLeftNotification(message: GroupUpdated, closedGroup: AccountId) { + storage.insertGroupInfoChange(message, closedGroup) + } + + private fun handleDeleteMemberContent(message: GroupUpdated, closedGroup: AccountId) { + val deleteMemberContent = message.inner.deleteMemberContent + val adminSig = if (deleteMemberContent.hasAdminSignature()) deleteMemberContent.adminSignature.toByteArray()!! else byteArrayOf() + + val hasValidAdminSignature = adminSig.isNotEmpty() && runCatching { + verifyAdminSignature( + closedGroup, + adminSig, + buildDeleteMemberContentSignature( + memberIds = deleteMemberContent.memberSessionIdsList.asSequence().map(::AccountId).asIterable(), + messageHashes = deleteMemberContent.messageHashesList, + timestamp = message.sentTimestamp!!, + ) + ) + }.isSuccess + + scope.launch { + try { + groupManagerV2.handleDeleteMemberContent( + groupId = closedGroup, + deleteMemberContent = deleteMemberContent, + timestamp = message.sentTimestamp!!, + sender = AccountId(message.sender!!), + senderIsVerifiedAdmin = hasValidAdminSignature + ) + } catch (e: Exception) { + Log.e("GroupUpdated", "Failed to handle delete member content", e) + } + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt index 4ee0cd8bc1..af8e0c4d2c 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt @@ -2,14 +2,14 @@ package org.session.libsession.messaging.sending_receiving import network.loki.messenger.libsession_util.SessionEncrypt import network.loki.messenger.libsession_util.util.BlindKeyAPI +import network.loki.messenger.libsession_util.util.KeyPair import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.MessageReceiver.Error -import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.hexEncodedPublicKey import org.session.libsignal.utilities.removingIdPrefixIfNeeded +@Deprecated("This class is deprecated and new code should try to decrypt/decode message using SessionProtocol API") object MessageDecrypter { /** @@ -20,9 +20,9 @@ object MessageDecrypter { * * @return the padded plaintext. */ - fun decrypt(ciphertext: ByteArray, x25519KeyPair: ECKeyPair): Pair { - val recipientX25519PrivateKey = x25519KeyPair.privateKey.serialize() - val recipientX25519PublicKey = Hex.fromStringCondensed(x25519KeyPair.hexEncodedPublicKey.removingIdPrefixIfNeeded()) + fun decrypt(ciphertext: ByteArray, x25519KeyPair: KeyPair): Pair { + val recipientX25519PrivateKey = x25519KeyPair.secretKey.data + val recipientX25519PublicKey = x25519KeyPair.pubKey.data.removingIdPrefixIfNeeded() val (id, data) = SessionEncrypt.decryptIncoming( x25519PubKey = recipientX25519PublicKey, x25519PrivKey = recipientX25519PrivateKey, diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt index 17f16ddbfe..d026b4fb26 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt @@ -4,10 +4,10 @@ import network.loki.messenger.libsession_util.SessionEncrypt import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.MessageSender.Error import org.session.libsignal.utilities.Hex -import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.removingIdPrefixIfNeeded +@Deprecated("This class is deprecated and new code should try to encrypt/encode message using SessionProtocol API") object MessageEncrypter { /** @@ -19,7 +19,7 @@ object MessageEncrypter { * @return the encrypted message. */ internal fun encrypt(plaintext: ByteArray, recipientHexEncodedX25519PublicKey: String): ByteArray { - val userED25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair + val userED25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair() val recipientX25519PublicKey = Hex.fromStringCondensed(recipientHexEncodedX25519PublicKey.removingIdPrefixIfNeeded()) try { @@ -30,26 +30,8 @@ object MessageEncrypter { ).data } catch (exception: Exception) { Log.d("Loki", "Couldn't encrypt message due to error: $exception.") - throw Error.EncryptionFailed + throw Error.EncryptionFailed() } } - internal fun encryptBlinded( - plaintext: ByteArray, - recipientBlindedId: String, - serverPublicKey: String - ): ByteArray { - if (IdPrefix.fromValue(recipientBlindedId) != IdPrefix.BLINDED) throw Error.SigningFailed - val userEdKeyPair = - MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair - val recipientBlindedPublicKey = Hex.fromStringCondensed(recipientBlindedId.removingIdPrefixIfNeeded()) - - return SessionEncrypt.encryptForBlindedRecipient( - message = plaintext, - myEd25519Privkey = userEdKeyPair.secretKey.data, - serverPubKey = Hex.fromStringCondensed(serverPublicKey), - recipientBlindId = byteArrayOf(0x15) + recipientBlindedPublicKey - ).data - } - } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt new file mode 100644 index 0000000000..9caf81ae8b --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt @@ -0,0 +1,297 @@ +package org.session.libsession.messaging.sending_receiving + +import dagger.Lazy +import network.loki.messenger.libsession_util.SessionEncrypt +import network.loki.messenger.libsession_util.protocol.DecodedEnvelope +import network.loki.messenger.libsession_util.protocol.DecodedPro +import network.loki.messenger.libsession_util.protocol.SessionProtocol +import network.loki.messenger.libsession_util.util.BitSet +import network.loki.messenger.libsession_util.util.asSequence +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.messages.Message +import org.session.libsession.messaging.messages.control.CallMessage +import org.session.libsession.messaging.messages.control.DataExtractionNotification +import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.messages.control.GroupUpdated +import org.session.libsession.messaging.messages.control.MessageRequestResponse +import org.session.libsession.messaging.messages.control.ReadReceipt +import org.session.libsession.messaging.messages.control.TypingIndicator +import org.session.libsession.messaging.messages.control.UnsendRequest +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.snode.SnodeClock +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.exceptions.NonRetryableException +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.IdPrefix +import org.thoughtcrime.securesms.pro.ProStatusManager +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.abs + +@Singleton +class MessageParser @Inject constructor( + private val configFactory: ConfigFactoryProtocol, + private val storage: StorageProtocol, + private val snodeClock: SnodeClock, + private val prefs: TextSecurePreferences, +) { + + //TODO: Obtain proBackendKey from somewhere + private val proBackendKey = ByteArray(32) + + // A faster way to check if the user is blocked than to go through RecipientRepository + private fun isUserBlocked(accountId: AccountId): Boolean { + return configFactory.withUserConfigs { it.contacts.get(accountId.hexString) } + ?.blocked == true + } + + + private fun createMessageFromProto(proto: SignalServiceProtos.Content, isGroupMessage: Boolean): Message { + val message = ReadReceipt.fromProto(proto) ?: + TypingIndicator.fromProto(proto) ?: + DataExtractionNotification.fromProto(proto) ?: + ExpirationTimerUpdate.fromProto(proto, isGroupMessage) ?: + UnsendRequest.fromProto(proto) ?: + MessageRequestResponse.fromProto(proto) ?: + CallMessage.fromProto(proto) ?: + GroupUpdated.fromProto(proto) ?: + VisibleMessage.fromProto(proto) + + if (message == null) { + throw NonRetryableException("Unknown message type") + } + + return message + } + + private fun parseMessage( + decodedEnvelope: DecodedEnvelope, + relaxSignatureCheck: Boolean, + checkForBlockStatus: Boolean, + isForGroup: Boolean, + currentUserId: AccountId, + currentUserBlindedIDs: List, + senderIdPrefix: IdPrefix + ): Pair { + return parseMessage( + sender = AccountId(senderIdPrefix, decodedEnvelope.senderX25519PubKey.data), + contentPlaintext = decodedEnvelope.contentPlainText.data, + pro = decodedEnvelope.decodedPro, + messageTimestampMs = decodedEnvelope.timestamp.toEpochMilli(), + relaxSignatureCheck = relaxSignatureCheck, + checkForBlockStatus = checkForBlockStatus, + isForGroup = isForGroup, + currentUserId = currentUserId, + currentUserBlindedIDs = currentUserBlindedIDs, + ) + } + + private fun parseMessage( + sender: AccountId, + contentPlaintext: ByteArray, + pro: DecodedPro?, + messageTimestampMs: Long, + relaxSignatureCheck: Boolean, + checkForBlockStatus: Boolean, + isForGroup: Boolean, + currentUserId: AccountId, + currentUserBlindedIDs: List, + ): Pair { + val proto = SignalServiceProtos.Content.parseFrom(contentPlaintext) + + // Check signature + if (proto.hasSigTimestampMs()) { + val diff = abs(proto.sigTimestampMs - messageTimestampMs) + if ( + (!relaxSignatureCheck && diff != 0L ) || + (relaxSignatureCheck && diff > TimeUnit.HOURS.toMillis(6))) { + throw NonRetryableException("Invalid signature timestamp") + } + } + + val message = createMessageFromProto(proto, isGroupMessage = isForGroup) + + // Blocked sender check + if (checkForBlockStatus && isUserBlocked(sender) && message.shouldDiscardIfBlocked()) { + throw NonRetryableException("Sender($sender) is blocked from sending message to us") + } + + // Valid self-send messages + val isSenderSelf = sender == currentUserId || sender in currentUserBlindedIDs + if (isSenderSelf && !message.isSelfSendValid) { + throw NonRetryableException("Ignoring self send message") + } + + // Fill in message fields + message.sender = sender.hexString + message.recipient = currentUserId.hexString + message.sentTimestamp = messageTimestampMs + message.receivedTimestamp = snodeClock.currentTimeMills() + message.isSenderSelf = isSenderSelf + + // Only process pro features post pro launch + if (prefs.forcePostPro()) { + (message as? VisibleMessage)?.proFeatures = buildSet { + pro?.proMessageFeatures?.asSequence()?.let(::addAll) + pro?.proProfileFeatures?.asSequence()?.let(::addAll) + } + } + + // Validate + var isValid = message.isValid() + // TODO: Legacy code: why this is check needed? + if (message is VisibleMessage && !isValid && proto.dataMessage.attachmentsCount != 0) { isValid = true } + if (!isValid) { + throw NonRetryableException("Invalid message") + } + + // Duplicate check + // TODO: Legacy code: this is most likely because we try to duplicate the message we just + // send (so that a new polling won't get the same message). At the moment it's the only reliable + // way to de-duplicate sent messages as we can add the "timestamp" before hand so that when + // message arrives back from server we can identify it. The logic can be removed if we can + // calculate message hash before sending it out so we can use the existing hash de-duplication + // mechanism. + if (storage.isDuplicateMessage(messageTimestampMs)) { + throw NonRetryableException("Duplicate message") + } + storage.addReceivedMessageTimestamp(messageTimestampMs) + + return message to proto + } + + + fun parse1o1Message( + data: ByteArray, + serverHash: String?, + currentUserEd25519PrivKey: ByteArray, + currentUserId: AccountId, + ): Pair { + val envelop = SessionProtocol.decodeFor1o1( + myEd25519PrivKey = currentUserEd25519PrivKey, + payload = data, + nowEpochMs = snodeClock.currentTimeMills(), + proBackendPubKey = proBackendKey, + ) + + return parseMessage( + decodedEnvelope = envelop, + relaxSignatureCheck = false, + checkForBlockStatus = true, + isForGroup = false, + senderIdPrefix = IdPrefix.STANDARD, + currentUserId = currentUserId, + currentUserBlindedIDs = emptyList(), + ).also { (message, _) -> + message.serverHash = serverHash + } + } + + fun parseGroupMessage( + data: ByteArray, + serverHash: String, + groupId: AccountId, + currentUserEd25519PrivKey: ByteArray, + currentUserId: AccountId, + ): Pair { + val keys = configFactory.withGroupConfigs(groupId) { + it.groupKeys.groupKeys() + } + + val decoded = SessionProtocol.decodeForGroup( + payload = data, + myEd25519PrivKey = currentUserEd25519PrivKey, + nowEpochMs = snodeClock.currentTimeMills(), + groupEd25519PublicKey = groupId.pubKeyBytes, + groupEd25519PrivateKeys = keys.toTypedArray(), + proBackendPubKey = proBackendKey + ) + + return parseMessage( + decodedEnvelope = decoded, + relaxSignatureCheck = false, + checkForBlockStatus = false, + isForGroup = true, + senderIdPrefix = IdPrefix.STANDARD, + currentUserId = currentUserId, + currentUserBlindedIDs = emptyList(), + ).also { (message, _) -> + message.serverHash = serverHash + } + } + + fun parseCommunityMessage( + msg: OpenGroupApi.Message, + currentUserId: AccountId, + currentUserBlindedIDs: List, + ): Pair? { + if (msg.data.isNullOrBlank()) { + return null + } + + val decoded = SessionProtocol.decodeForCommunity( + payload = Base64.decode(msg.data), + nowEpochMs = snodeClock.currentTimeMills(), + proBackendPubKey = proBackendKey, + ) + + val sender = AccountId(msg.sessionId) + + return parseMessage( + contentPlaintext = decoded.contentPlainText.data, + pro = decoded.decodedPro, + relaxSignatureCheck = true, + checkForBlockStatus = false, + isForGroup = false, + currentUserId = currentUserId, + sender = sender, + messageTimestampMs = (msg.posted * 1000).toLong(), + currentUserBlindedIDs = currentUserBlindedIDs, + ).also { (message, _) -> + message.openGroupServerMessageID = msg.id + } + } + + fun parseCommunityDirectMessage( + msg: OpenGroupApi.DirectMessage, + communityServerPubKeyHex: String, + currentUserEd25519PrivKey: ByteArray, + currentUserId: AccountId, + currentUserBlindedIDs: List, + ): Pair { + val (senderId, plaintext) = SessionEncrypt.decryptForBlindedRecipient( + ciphertext = Base64.decode(msg.message), + myEd25519Privkey = currentUserEd25519PrivKey, + openGroupPubkey = Hex.fromStringCondensed(communityServerPubKeyHex), + senderBlindedId = Hex.fromStringCondensed(msg.sender), + recipientBlindId = Hex.fromStringCondensed(msg.recipient), + ) + + val decoded = SessionProtocol.decodeForCommunity( + payload = plaintext.data, + nowEpochMs = snodeClock.currentTimeMills(), + proBackendPubKey = proBackendKey, + ) + + val sender = Address.Standard(AccountId(senderId)) + + return parseMessage( + contentPlaintext = decoded.contentPlainText.data, + pro = decoded.decodedPro, + relaxSignatureCheck = true, + checkForBlockStatus = false, + isForGroup = false, + currentUserId = currentUserId, + sender = sender.accountId, + messageTimestampMs = (msg.postedAt * 1000), + currentUserBlindedIDs = currentUserBlindedIDs, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt index 9e05ecce3e..e2b42f3976 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt @@ -1,7 +1,8 @@ package org.session.libsession.messaging.sending_receiving import network.loki.messenger.libsession_util.util.BlindKeyAPI -import org.session.libsession.messaging.MessagingModuleConfiguration +import network.loki.messenger.libsession_util.util.KeyPair +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.messages.control.DataExtractionNotification @@ -21,9 +22,15 @@ import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton import kotlin.math.abs -object MessageReceiver { +@Deprecated("This class only exists so the old BatchMessageReceiver can function. New code should use MessageHandler directly.") +@Singleton +class MessageReceiver @Inject constructor( + private val storage: StorageProtocol, +) { internal sealed class Error(message: String) : Exception(message) { object DuplicateMessage: Error("Duplicate message.") @@ -60,7 +67,6 @@ object MessageReceiver { currentClosedGroups: Set?, closedGroupSessionId: String? = null, ): Pair { - val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey() val isOpenGroupMessage = (openGroupServerID != null) var plaintext: ByteArray? = null @@ -91,7 +97,7 @@ object MessageReceiver { plaintext = decryptionResult.first sender = decryptionResult.second } else { - val userX25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair() + val userX25519KeyPair = storage.getUserX25519KeyPair() val decryptionResult = MessageDecrypter.decrypt(envelopeContent.toByteArray(), userX25519KeyPair) plaintext = decryptionResult.first sender = decryptionResult.second @@ -105,10 +111,10 @@ object MessageReceiver { sender = envelope.source groupPublicKey = hexEncodedGroupPublicKey } else { - if (!MessagingModuleConfiguration.shared.storage.isLegacyClosedGroup(hexEncodedGroupPublicKey)) { + if (!storage.isLegacyClosedGroup(hexEncodedGroupPublicKey)) { throw Error.InvalidGroupPublicKey } - val encryptionKeyPairs = MessagingModuleConfiguration.shared.storage.getClosedGroupEncryptionKeyPairs(hexEncodedGroupPublicKey) + val encryptionKeyPairs = storage.getClosedGroupEncryptionKeyPairs(hexEncodedGroupPublicKey) if (encryptionKeyPairs.isEmpty()) { throw Error.NoGroupKeyPair } @@ -117,7 +123,11 @@ object MessageReceiver { var encryptionKeyPair = encryptionKeyPairs.removeAt(encryptionKeyPairs.lastIndex) fun decrypt() { try { - val decryptionResult = MessageDecrypter.decrypt(envelopeContent.toByteArray(), encryptionKeyPair) + val decryptionResult = MessageDecrypter.decrypt(envelopeContent.toByteArray(), + KeyPair( + pubKey = encryptionKeyPair.publicKey.serialize(), + secretKey = encryptionKeyPair.privateKey.serialize() + )) plaintext = decryptionResult.first sender = decryptionResult.second } catch (e: Exception) { @@ -172,7 +182,7 @@ object MessageReceiver { } val isUserBlindedSender = sender == openGroupPublicKey?.let { BlindKeyAPI.blind15KeyPairOrNull( - ed25519SecretKey = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!.secretKey.data, + ed25519SecretKey = storage.getUserED25519KeyPair()!!.secretKey.data, serverPubKey = Hex.fromStringCondensed(it), ) }?.let { AccountId(IdPrefix.BLINDED, it.pubKey.data).hexString } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt index 0d43629fc9..a1db7543b2 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt @@ -1,8 +1,8 @@ package org.session.libsession.messaging.sending_receiving +import network.loki.messenger.libsession_util.util.BitSet import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.ProfileUpdateHandler -import org.session.libsession.messaging.messages.ProfileUpdateHandler.Updates.Companion.toUpdates import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.signal.IncomingMediaMessage import org.session.libsession.messaging.messages.visible.VisibleMessage @@ -12,8 +12,8 @@ import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.updateContact import org.session.libsession.utilities.upsertContact +import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.guava.Optional import org.thoughtcrime.securesms.database.BlindMappingRepository import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.RecipientRepository @@ -33,12 +33,16 @@ class MessageRequestResponseHandler @Inject constructor( private val blindMappingRepository: BlindMappingRepository, ) { - suspend fun handleVisibleMessage(message: VisibleMessage) { + fun handleVisibleMessage( + ctx: ReceivedMessageProcessor.MessageProcessingContext?, + message: VisibleMessage + ) { val (sender, receiver) = fetchSenderAndReceiver(message) ?: return - val allBlindedAddresses = blindMappingRepository.calculateReverseMappings( - contactAddress = sender.address as Address.Standard - ) + val senderAddress = sender.address as Address.Standard + + val allBlindedAddresses = ctx?.getBlindIDMapping(senderAddress) + ?: blindMappingRepository.calculateReverseMappings(senderAddress) // Do we have an existing message request (including blinded requests)? val hasMessageRequest = configFactory.withUserConfigs { configs -> @@ -54,6 +58,7 @@ class MessageRequestResponseHandler @Inject constructor( if (hasMessageRequest) { handleRequestResponse( + ctx = ctx, messageSender = sender, messageReceiver = receiver, messageTimestampMs = message.sentTimestamp!!, @@ -61,10 +66,15 @@ class MessageRequestResponseHandler @Inject constructor( } } - suspend fun handleExplicitRequestResponseMessage(message: MessageRequestResponse) { + fun handleExplicitRequestResponseMessage( + ctx: ReceivedMessageProcessor.MessageProcessingContext?, + message: MessageRequestResponse, + proto: SignalServiceProtos.Content, + ) { val (sender, receiver) = fetchSenderAndReceiver(message) ?: return // Always handle explicit request response handleRequestResponse( + ctx = ctx, messageSender = sender, messageReceiver = receiver, messageTimestampMs = message.sentTimestamp!!, @@ -72,7 +82,7 @@ class MessageRequestResponseHandler @Inject constructor( // Always process the profile update if any. We don't need // to process profile for other kind of messages as they should be handled elsewhere - message.profile?.toUpdates()?.let { updates -> + ProfileUpdateHandler.Updates.create(proto)?.let { updates -> profileUpdateHandler.get().handleProfileUpdate( senderId = (sender.address as Address.Standard).accountId, updates = updates, @@ -81,8 +91,8 @@ class MessageRequestResponseHandler @Inject constructor( } } - private suspend fun fetchSenderAndReceiver(message: Message): Pair? { - val messageSender = recipientRepository.getRecipient( + private fun fetchSenderAndReceiver(message: Message): Pair? { + val messageSender = recipientRepository.getRecipientSync( requireNotNull(message.sender) { "MessageRequestResponse must have a sender" }.toAddress() @@ -92,7 +102,7 @@ class MessageRequestResponseHandler @Inject constructor( Log.e(TAG, "MessageRequestResponse sender must be a standard address, but got: ${messageSender.address.debugString}") null } else { - messageSender to recipientRepository.getRecipient( + messageSender to recipientRepository.getRecipientSync( requireNotNull(message.recipient) { "MessageRequestResponse must have a receiver" }.toAddress() @@ -101,6 +111,7 @@ class MessageRequestResponseHandler @Inject constructor( } private fun handleRequestResponse( + ctx: ReceivedMessageProcessor.MessageProcessingContext?, messageSender: Recipient, messageReceiver: Recipient, messageTimestampMs: Long, @@ -141,21 +152,20 @@ class MessageRequestResponseHandler @Inject constructor( if (!didApproveMe) { mmsDatabase.insertSecureDecryptedMessageInbox( retrieved = IncomingMediaMessage( - messageSender.address, - messageTimestampMs, - -1, - 0L, - 0L, - true, - false, - Optional.absent(), - Optional.absent(), - Optional.absent(), - null, - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent() + from = messageSender.address, + sentTimeMillis = messageTimestampMs, + expiresIn = 0L, + expireStartedAt = 0L, + isMessageRequestResponse = true, + hasMention = false, + body = null, + group = null, + attachments = emptyList(), + proFeatures = emptySet(), + messageContent = null, + quote = null, + linkPreviews = emptyList(), + dataExtractionNotification = null ), threadId, runThreadUpdate = true, @@ -164,7 +174,8 @@ class MessageRequestResponseHandler @Inject constructor( // Find all blinded conversations we have with this sender, move all the messages // from the blinded conversations to the standard conversation. - val blindedConversationAddresses = blindMappingRepository.calculateReverseMappings(messageSender.address) + val blindedConversationAddresses = (ctx?.getBlindIDMapping(messageSender.address) + ?: blindMappingRepository.calculateReverseMappings(messageSender.address)) .mapTo(hashSetOf()) { (c, id) -> Address.CommunityBlindedId( serverUrl = c.baseUrl, diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index 0b5158ce5f..590dec0032 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -1,18 +1,22 @@ package org.session.libsession.messaging.sending_receiving -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.async +import com.google.protobuf.ByteString +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.PRIORITY_HIDDEN +import network.loki.messenger.libsession_util.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.Namespace +import network.loki.messenger.libsession_util.ReadableUserProfile +import network.loki.messenger.libsession_util.protocol.SessionProtocol import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.ExpiryMode -import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.jobs.MessageSendJob import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.applyExpiryMode @@ -26,66 +30,129 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability import org.session.libsession.messaging.open_groups.OpenGroupMessage -import org.session.libsession.messaging.utilities.MessageWrapper import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeAPI.nowWithOffset +import org.session.libsession.snode.SnodeClock import org.session.libsession.snode.SnodeMessage import org.session.libsession.utilities.Address -import org.session.libsession.utilities.SSKEnvironment -import org.session.libsignal.crypto.PushTransportDetails +import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.defaultRequiresAuth -import org.session.libsignal.utilities.hasNamespaces -import org.session.libsignal.utilities.hexEncodedPublicKey +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.pro.copyFromLibSession +import org.thoughtcrime.securesms.pro.db.ProDatabase +import org.thoughtcrime.securesms.service.ExpiringMessageManager import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton import kotlin.coroutines.cancellation.CancellationException import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview as SignalLinkPreview import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel as SignalQuote -object MessageSender { +@Singleton +class MessageSender @Inject constructor( + private val storage: StorageProtocol, + private val configFactory: ConfigFactoryProtocol, + private val recipientRepository: RecipientRepository, + private val messageDataProvider: MessageDataProvider, + private val messageSendJobFactory: MessageSendJob.Factory, + private val messageExpirationManager: ExpiringMessageManager, + private val proDatabase: ProDatabase, + private val snodeClock: SnodeClock, + @param:ManagerScope private val scope: CoroutineScope, +) { // Error - sealed class Error(val description: String) : Exception(description) { - object InvalidMessage : Error("Invalid message.") - object ProtoConversionFailed : Error("Couldn't convert message to proto.") - object NoUserED25519KeyPair : Error("Couldn't find user ED25519 key pair.") - object SigningFailed : Error("Couldn't sign message.") - object EncryptionFailed : Error("Couldn't encrypt message.") - data class InvalidDestination(val destination: Destination): Error("Can't send this way to $destination") + sealed class Error(val description: String, cause: Throwable? = null) : Exception(description, cause) { + class InvalidMessage : Error("Invalid message.") + class ProtoConversionFailed(cause: Throwable) : Error("Couldn't convert message to proto.", cause) + class NoUserED25519KeyPair : Error("Couldn't find user ED25519 key pair.") + class SigningFailed : Error("Couldn't sign message.") + class EncryptionFailed : Error("Couldn't encrypt message.") // Closed groups - object NoThread : Error("Couldn't find a thread associated with the given group public key.") - object NoKeyPair: Error("Couldn't find a private key associated with the given group public key.") - object InvalidClosedGroupUpdate : Error("Invalid group update.") + class InvalidClosedGroupUpdate : Error("Invalid group update.") internal val isRetryable: Boolean = when (this) { - is InvalidMessage, ProtoConversionFailed, InvalidClosedGroupUpdate -> false + is InvalidMessage, is ProtoConversionFailed, is InvalidClosedGroupUpdate -> false else -> true } } + + private fun SignalServiceProtos.DataMessage.Builder.copyProfileFromConfig() { + configFactory.withUserConfigs { + val pic = it.userProfile.getPic() + + profileBuilder.setDisplayName(it.userProfile.getName().orEmpty()) + .setProfilePicture(pic.url) + .setLastProfileUpdateSeconds(it.userProfile.getProfileUpdatedSeconds()) + + setProfileKey(ByteString.copyFrom(pic.keyAsByteArray)) + } + } + + private fun SignalServiceProtos.MessageRequestResponse.Builder.copyProfileFromConfig() { + configFactory.withUserConfigs { + val pic = it.userProfile.getPic() + + profileBuilder.setDisplayName(it.userProfile.getName().orEmpty()) + .setProfilePicture(pic.url) + .setLastProfileUpdateSeconds(it.userProfile.getProfileUpdatedSeconds()) + + setProfileKey(ByteString.copyFrom(pic.keyAsByteArray)) + } + } + // Convenience suspend fun sendNonDurably(message: Message, destination: Destination, isSyncMessage: Boolean) { - return if (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup || destination is Destination.OpenGroupInbox) { + return if (destination is Destination.OpenGroup || destination is Destination.OpenGroupInbox) { sendToOpenGroupDestination(destination, message) } else { sendToSnodeDestination(destination, message, isSyncMessage) } } + private fun buildProto(msg: Message): SignalServiceProtos.Content { + try { + val builder = SignalServiceProtos.Content.newBuilder() + + msg.toProto(builder, messageDataProvider) + + // Attach pro proof + configFactory.withUserConfigs { it.userProfile.getProConfig() }?.proProof?.let { proof -> + builder.proMessageBuilder.proofBuilder.copyFromLibSession(proof) + } + + // Attach the user's profile if needed + when { + builder.hasDataMessage() && !builder.dataMessageBuilder.hasProfile() -> { + builder.dataMessageBuilder.copyProfileFromConfig() + } + + builder.hasMessageRequestResponse() && !builder.messageRequestResponseBuilder.hasProfile() -> { + builder.messageRequestResponseBuilder.copyProfileFromConfig() + } + } + + return builder.build() + } catch (e: Exception) { + throw Error.ProtoConversionFailed(e) + } + } + // One-on-One Chats & Closed Groups - @Throws(Exception::class) fun buildWrappedMessageToSnode(destination: Destination, message: Message, isSyncMessage: Boolean): SnodeMessage { - val storage = MessagingModuleConfiguration.shared.storage - val configFactory = MessagingModuleConfiguration.shared.configFactory val userPublicKey = storage.getUserPublicKey() + val userEd25519PrivKey = requireNotNull(storage.getUserED25519KeyPair()?.secretKey?.data) { + "Missing user key" + } // Set the timestamp, sender and recipient - val messageSendTime = nowWithOffset + val messageSendTime = snodeClock.currentTimeMills() if (message.sentTimestamp == null) { message.sentTimestamp = messageSendTime // Visible messages will already have their sent timestamp set @@ -95,15 +162,15 @@ object MessageSender { // SHARED CONFIG when (destination) { is Destination.Contact -> message.recipient = destination.publicKey - is Destination.LegacyClosedGroup -> message.recipient = destination.groupPublicKey is Destination.ClosedGroup -> message.recipient = destination.publicKey - else -> throw IllegalStateException("Destination should not be an open group.") + is Destination.OpenGroup, + is Destination.OpenGroupInbox -> error("Destination should not be an open group.") } val isSelfSend = (message.recipient == userPublicKey) // Validate the message if (!message.isValid()) { - throw Error.InvalidMessage + throw Error.InvalidMessage() } // Stop here if this is a self-send, unless it's: // • a configuration message @@ -113,79 +180,41 @@ object MessageSender { && !isSyncMessage && message !is UnsendRequest ) { - throw Error.InvalidMessage - } - // Attach the user's profile if needed - if (message is VisibleMessage) { - message.profile = storage.getUserProfile() + throw Error.InvalidMessage() } - if (message is MessageRequestResponse) { - message.profile = storage.getUserProfile() - } - // Convert it to protobuf - val proto = message.toProto()?.toBuilder() ?: throw Error.ProtoConversionFailed - if (message is GroupUpdated) { - if (message.profile != null) { - proto.mergeDataMessage(message.profile.toProto()) - } - } - - // Set the timestamp on the content so it can be verified against envelope timestamp - proto.setSigTimestampMs(message.sentTimestamp!!) - - // Serialize the protobuf - val plaintext = PushTransportDetails.getPaddedMessageBody(proto.build().toByteArray()) - // Envelope information - val kind: SignalServiceProtos.Envelope.Type - val senderPublicKey: String - when (destination) { + val messageContent = when (destination) { is Destination.Contact -> { - kind = SignalServiceProtos.Envelope.Type.SESSION_MESSAGE - senderPublicKey = "" - } - is Destination.LegacyClosedGroup -> { - kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE - senderPublicKey = destination.groupPublicKey + SessionProtocol.encodeFor1o1( + plaintext = buildProto(message).toByteArray(), + myEd25519PrivKey = userEd25519PrivKey, + timestampMs = message.sentTimestamp!!, + recipientPubKey = Hex.fromStringCondensed(destination.publicKey), + proRotatingEd25519PrivKey = null, + ) } - is Destination.ClosedGroup -> { - kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE - senderPublicKey = destination.publicKey - } - else -> throw IllegalStateException("Destination should not be open group.") - } - // Encrypt the serialized protobuf - val ciphertext = when (destination) { - is Destination.Contact -> MessageEncrypter.encrypt(plaintext, destination.publicKey) - is Destination.LegacyClosedGroup -> { - val encryptionKeyPair = - MessagingModuleConfiguration.shared.storage.getLatestClosedGroupEncryptionKeyPair( - destination.groupPublicKey - )!! - MessageEncrypter.encrypt(plaintext, encryptionKeyPair.hexEncodedPublicKey) - } is Destination.ClosedGroup -> { - val envelope = MessageWrapper.createEnvelope(kind, message.sentTimestamp!!, senderPublicKey, proto.build().toByteArray()) - configFactory.withGroupConfigs(AccountId(destination.publicKey)) { - it.groupKeys.encrypt(envelope.toByteArray()) - } + SessionProtocol.encodeForGroup( + plaintext = buildProto(message).toByteArray(), + myEd25519PrivKey = userEd25519PrivKey, + timestampMs = message.sentTimestamp!!, + groupEd25519PublicKey = Hex.fromStringCondensed(destination.publicKey), + groupEd25519PrivateKey = configFactory.withGroupConfigs(AccountId(destination.publicKey)) { + it.groupKeys.groupEncKey() + }, + proRotatingEd25519PrivKey = null + ) } - else -> throw IllegalStateException("Destination should not be open group.") - } - // Wrap the result using envelope information - val wrappedMessage = when (destination) { - is Destination.ClosedGroup -> { - // encrypted bytes from the above closed group encryption and envelope steps - ciphertext - } - else -> MessageWrapper.wrap(kind, message.sentTimestamp!!, senderPublicKey, ciphertext) + + is Destination.OpenGroup, + is Destination.OpenGroupInbox -> error("Destination should not be an open group.") } - val base64EncodedData = Base64.encodeBytes(wrappedMessage) + // Send the result return SnodeMessage( message.recipient!!, - base64EncodedData, + data = Base64.encodeBytes(messageContent), ttl = getSpecifiedTtl(message, isSyncMessage) ?: message.ttl, messageSendTime ) @@ -193,8 +222,6 @@ object MessageSender { // One-on-One Chats & Closed Groups private suspend fun sendToSnodeDestination(destination: Destination, message: Message, isSyncMessage: Boolean = false) = supervisorScope { - val configFactory = MessagingModuleConfiguration.shared.configFactory - // Set the failure handler (need it here already for precondition failure handling) fun handleFailure(error: Exception) { handleFailedMessageSend(message, error, isSyncMessage) @@ -202,57 +229,39 @@ object MessageSender { try { val snodeMessage = buildWrappedMessageToSnode(destination, message, isSyncMessage) - // TODO: this might change in future for config messages - val forkInfo = SnodeAPI.forkInfo - val namespaces: List = when { - destination is Destination.LegacyClosedGroup - && forkInfo.defaultRequiresAuth() -> listOf(Namespace.UNAUTHENTICATED_CLOSED_GROUP()) - - destination is Destination.LegacyClosedGroup - && forkInfo.hasNamespaces() -> listOf( - Namespace.UNAUTHENTICATED_CLOSED_GROUP(), - Namespace.DEFAULT - ()) - destination is Destination.ClosedGroup -> listOf(Namespace.GROUP_MESSAGES()) - - else -> listOf(Namespace.DEFAULT()) - } - - val sendTasks = namespaces.map { namespace -> - if (destination is Destination.ClosedGroup) { - val groupAuth = requireNotNull(configFactory.getGroupAuth(AccountId(destination.publicKey))) { - "Unable to authorize group message send" - } + val sendResult = runCatching { + when (destination) { + is Destination.ClosedGroup -> { + val groupAuth = requireNotNull(configFactory.getGroupAuth(AccountId(destination.publicKey))) { + "Unable to authorize group message send" + } - async { SnodeAPI.sendMessage( auth = groupAuth, message = snodeMessage, - namespace = namespace, + namespace = Namespace.GROUP_MESSAGES(), ) } - } else { - async { - SnodeAPI.sendMessage(snodeMessage, auth = null, namespace = namespace) + is Destination.Contact -> { + SnodeAPI.sendMessage(snodeMessage, auth = null, namespace = Namespace.DEFAULT()) } + is Destination.OpenGroup, + is Destination.OpenGroupInbox -> throw IllegalStateException("Destination should not be an open group.") } } - val sendTaskResults = sendTasks.map { - runCatching { it.await() } - } - - val firstSuccess = sendTaskResults.firstOrNull { it.isSuccess }?.getOrNull() - if (firstSuccess != null) { - message.serverHash = firstSuccess.hash + if (sendResult.isSuccess) { + message.serverHash = sendResult.getOrThrow().hash handleSuccessfulMessageSend(message, destination, isSyncMessage) } else { - // If all tasks failed, throw the first exception - throw sendTaskResults.first().exceptionOrNull()!! + throw sendResult.exceptionOrNull()!! } } catch (exception: Exception) { - handleFailure(exception) + if (exception !is CancellationException) { + handleFailure(exception) + } + throw exception } } @@ -275,7 +284,7 @@ object MessageSender { return message.run { (if (isSyncMessage && this is VisibleMessage) syncTarget else recipient) ?.let(Address::fromSerialized) - ?.let(MessagingModuleConfiguration.shared.recipientRepository::getRecipientSync) + ?.let(recipientRepository::getRecipientSync) ?.expiryMode ?.takeIf { it is ExpiryMode.AfterSend || isSyncMessage } ?.expiryMillis @@ -285,10 +294,8 @@ object MessageSender { // Open Groups private suspend fun sendToOpenGroupDestination(destination: Destination, message: Message) { - val storage = MessagingModuleConfiguration.shared.storage - val configFactory = MessagingModuleConfiguration.shared.configFactory if (message.sentTimestamp == null) { - message.sentTimestamp = nowWithOffset + message.sentTimestamp = snodeClock.currentTimeMills() } // Attach the blocks message requests info configFactory.withUserConfigs { configs -> @@ -296,10 +303,10 @@ object MessageSender { message.blocksMessageRequests = !configs.userProfile.getCommunityMessageRequests() } } - val userEdKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!! - var serverCapabilities = listOf() + val userEdKeyPair = storage.getUserED25519KeyPair()!! + var serverCapabilities: List var blindedPublicKey: ByteArray? = null - when(destination) { + when (destination) { is Destination.OpenGroup -> { serverCapabilities = storage.getServerCapabilities(destination.server).orEmpty() storage.getOpenGroupPublicKey(destination.server)?.let { @@ -316,16 +323,9 @@ object MessageSender { serverPubKey = Hex.fromStringCondensed(destination.serverPublicKey), )?.pubKey?.data } - is Destination.LegacyOpenGroup -> { - serverCapabilities = storage.getServerCapabilities(destination.server).orEmpty() - storage.getOpenGroupPublicKey(destination.server)?.let { - blindedPublicKey = BlindKeyAPI.blind15KeyPairOrNull( - ed25519SecretKey = userEdKeyPair.secretKey.data, - serverPubKey = Hex.fromStringCondensed(it), - )?.pubKey?.data - } - } - else -> {} + + is Destination.ClosedGroup, + is Destination.Contact -> error("Destination must be an open group.") } val messageSender = if (serverCapabilities.contains(Capability.BLIND.name.lowercase()) && blindedPublicKey != null) { AccountId(IdPrefix.BLINDED, blindedPublicKey).hexString @@ -335,24 +335,21 @@ object MessageSender { message.sender = messageSender try { - // Attach the user's profile if needed - if (message is VisibleMessage) { - message.profile = storage.getUserProfile() - } - val content = message.toProto()!!.toBuilder() - .setSigTimestampMs(message.sentTimestamp!!) - .build() + val content = buildProto(message) when (destination) { is Destination.OpenGroup -> { - val whisperMods = if (destination.whisperTo.isNullOrEmpty() && destination.whisperMods) "mods" else null + val whisperMods = if (destination.whisperTo.isEmpty() && destination.whisperMods) "mods" else null message.recipient = "${destination.server}.${destination.roomToken}.${destination.whisperTo}.$whisperMods" // Validate the message if (message !is VisibleMessage || !message.isValid()) { - throw Error.InvalidMessage + throw Error.InvalidMessage() } - val messageBody = content.toByteArray() - val plaintext = PushTransportDetails.getPaddedMessageBody(messageBody) + val plaintext = SessionProtocol.encodeForCommunity( + plaintext = content.toByteArray(), + proRotatingEd25519PrivKey = null + ) + val openGroupMessage = OpenGroupMessage( sender = message.sender, sentTimestamp = message.sentTimestamp!!, @@ -376,15 +373,17 @@ object MessageSender { message.recipient = destination.blindedPublicKey // Validate the message if (message !is VisibleMessage || !message.isValid()) { - throw Error.InvalidMessage + throw Error.InvalidMessage() } - val messageBody = content.toByteArray() - val plaintext = PushTransportDetails.getPaddedMessageBody(messageBody) - val ciphertext = MessageEncrypter.encryptBlinded( - plaintext, - destination.blindedPublicKey, - destination.serverPublicKey + val ciphertext = SessionProtocol.encodeForCommunityInbox( + plaintext = content.toByteArray(), + myEd25519PrivKey = userEdKeyPair.secretKey.data, + timestampMs = message.sentTimestamp!!, + recipientPubKey = Hex.fromStringCondensed(destination.blindedPublicKey), + communityServerPubKey = Hex.fromStringCondensed(destination.serverPublicKey), + proRotatingEd25519PrivKey = null, ) + val base64EncodedData = Base64.encodeBytes(ciphertext) val response = OpenGroupApi.sendDirectMessage( base64EncodedData, @@ -405,8 +404,7 @@ object MessageSender { } // Result Handling - fun handleSuccessfulMessageSend(message: Message, destination: Destination, isSyncMessage: Boolean = false, openGroupSentTimestamp: Long = -1) { - val storage = MessagingModuleConfiguration.shared.storage + private fun handleSuccessfulMessageSend(message: Message, destination: Destination, isSyncMessage: Boolean = false, openGroupSentTimestamp: Long = -1) { val userPublicKey = storage.getUserPublicKey()!! // Ignore future self-sends storage.addReceivedMessageTimestamp(message.sentTimestamp!!) @@ -427,21 +425,9 @@ object MessageSender { storage.clearErrorMessage(messageId) // Track the open group server message ID - val messageIsAddressedToCommunity = message.openGroupServerMessageID != null && (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup) + val messageIsAddressedToCommunity = message.openGroupServerMessageID != null && (destination is Destination.OpenGroup) if (messageIsAddressedToCommunity) { - val address = when (destination) { - is Destination.LegacyOpenGroup -> { - Address.Community(destination.server, destination.roomToken) - } - - is Destination.OpenGroup -> { - Address.Community(destination.server, destination.roomToken) - } - - else -> { - throw Exception("Destination was a different destination than we were expecting") - } - } + val address = Address.Community(destination.server, destination.roomToken) val communityThreadID = storage.getThreadId(address) if (communityThreadID != null && communityThreadID >= 0) { storage.setOpenGroupServerMessageID( @@ -459,7 +445,7 @@ object MessageSender { storage.updateSentTimestamp(messageId, message.sentTimestamp!!) // Start the disappearing messages timer if needed - SSKEnvironment.shared.messageExpirationManager.onMessageSent(message) + messageExpirationManager.onMessageSent(message) } ?: run { storage.updateReactionIfNeeded(message, message.sender?:userPublicKey, openGroupSentTimestamp) } @@ -472,7 +458,7 @@ object MessageSender { if (message is ExpirationTimerUpdate) message.syncTarget = destination.publicKey message.id?.let(storage::markAsSyncing) - GlobalScope.launch { + scope.launch { try { sendToSnodeDestination(Destination.Contact(userPublicKey), message, true) } catch (ec: Exception) { @@ -483,12 +469,10 @@ object MessageSender { } fun handleFailedMessageSend(message: Message, error: Exception, isSyncMessage: Boolean = false) { - val storage = MessagingModuleConfiguration.shared.storage - val messageId = message.id ?: return // no need to handle if message is marked as deleted - if(MessagingModuleConfiguration.shared.messageDataProvider.isDeletedMessage(messageId)){ + if (messageDataProvider.isDeletedMessage(messageId)){ return } @@ -497,9 +481,7 @@ object MessageSender { } // Convenience - @JvmStatic fun send(message: VisibleMessage, address: Address, quote: SignalQuote?, linkPreview: SignalLinkPreview?) { - val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider val messageId = message.id if (messageId?.mms == true) { message.attachmentIDs.addAll(messageDataProvider.getAttachmentIDsFor(messageId.id)) @@ -517,24 +499,23 @@ object MessageSender { send(message, address) } - @JvmStatic @JvmOverloads fun send(message: Message, address: Address, statusCallback: SendChannel>? = null) { - val threadID = MessagingModuleConfiguration.shared.storage.getThreadId(address) + val threadID = storage.getThreadId(address) message.applyExpiryMode(address) message.threadID = threadID val destination = Destination.from(address) - val job = MessagingModuleConfiguration.shared.messageSendJobFactory.create(message, destination, statusCallback) + val job = messageSendJobFactory.create(message, destination, statusCallback) JobQueue.shared.add(job) // if we are sending a 'Note to Self' make sure it is not hidden if( message is VisibleMessage && - address.toString() == MessagingModuleConfiguration.shared.storage.getUserPublicKey() && + address.toString() == storage.getUserPublicKey() && // only show the NTS if it is currently marked as hidden - MessagingModuleConfiguration.shared.configFactory.withUserConfigs { it.userProfile.getNtsPriority() == PRIORITY_HIDDEN } + configFactory.withUserConfigs { it.userProfile.getNtsPriority() == PRIORITY_HIDDEN } ){ // update config in case it was marked as hidden there - MessagingModuleConfiguration.shared.configFactory.withMutableUserConfigs { + configFactory.withMutableUserConfigs { it.userProfile.setNtsPriority(PRIORITY_VISIBLE) } } @@ -547,7 +528,7 @@ object MessageSender { } suspend fun sendNonDurably(message: Message, address: Address, isSyncMessage: Boolean) { - val threadID = MessagingModuleConfiguration.shared.storage.getThreadId(address) + val threadID = storage.getThreadId(address) message.threadID = threadID val destination = Destination.from(address) sendNonDurably(message, destination, isSyncMessage) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index 277efe3054..979e83c8cb 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -10,12 +10,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import network.loki.messenger.R -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.PRIORITY_HIDDEN +import network.loki.messenger.libsession_util.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.util.BaseCommunityInfo import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.ExpiryMode +import network.loki.messenger.libsession_util.util.Util import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth @@ -53,6 +54,7 @@ import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.MessageType import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientData @@ -83,6 +85,7 @@ internal fun MessageReceiver.isBlocked(publicKey: String): Boolean { return recipient?.blocked == true } +@Deprecated(replaceWith = ReplaceWith("ReceivedMessageProcessor"), message = "Use ReceivedMessageProcessor instead") @Singleton class ReceivedMessageHandler @Inject constructor( @param:ApplicationContext private val context: Context, @@ -100,6 +103,7 @@ class ReceivedMessageHandler @Inject constructor( @param:ManagerScope private val scope: CoroutineScope, private val configFactory: ConfigFactoryProtocol, private val messageRequestResponseHandler: Provider, + private val prefs: TextSecurePreferences, ) { suspend fun handle( @@ -114,7 +118,11 @@ class ReceivedMessageHandler @Inject constructor( when (message) { is ReadReceipt -> handleReadReceipt(message) is TypingIndicator -> handleTypingIndicator(message) - is GroupUpdated -> handleGroupUpdated(message, (threadAddress as? Address.Group)?.accountId) + is GroupUpdated -> handleGroupUpdated( + message = message, + closedGroup = (threadAddress as? Address.Group)?.accountId, + proto = proto + ) is ExpirationTimerUpdate -> { // For groupsv2, there are dedicated mechanisms for handling expiration timers, and // we want to avoid the 1-to-1 message format which is unauthenticated in a group settings. @@ -129,7 +137,7 @@ class ReceivedMessageHandler @Inject constructor( } is DataExtractionNotification -> handleDataExtractionNotification(message) is UnsendRequest -> handleUnsendRequest(message) - is MessageRequestResponse -> messageRequestResponseHandler.get().handleExplicitRequestResponseMessage(message) + is MessageRequestResponse -> messageRequestResponseHandler.get().handleExplicitRequestResponseMessage(null, message, proto) is VisibleMessage -> handleVisibleMessage( message = message, proto = proto, @@ -183,6 +191,9 @@ class ReceivedMessageHandler @Inject constructor( } private fun showTypingIndicatorIfNeeded(senderPublicKey: String) { + // We don't want to show other people's indicators if the toggle is off + if(!prefs.isTypingIndicatorsEnabled()) return + val address = Address.fromSerialized(senderPublicKey) val threadID = storage.getThreadId(address) ?: return typingIndicators.didReceiveTypingStartedMessage(threadID, address, 1) @@ -213,7 +224,6 @@ class ReceivedMessageHandler @Inject constructor( val senderPublicKey = message.sender!! val notification: DataExtractionNotificationInfoMessage = when(message.kind) { - is DataExtractionNotification.Kind.Screenshot -> DataExtractionNotificationInfoMessage(DataExtractionNotificationInfoMessage.Kind.SCREENSHOT) is DataExtractionNotification.Kind.MediaSaved -> DataExtractionNotificationInfoMessage(DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED) else -> return } @@ -296,7 +306,7 @@ class ReceivedMessageHandler @Inject constructor( // Do nothing if the message was outdated if (messageIsOutdated(message, context.threadId)) { return null } - messageRequestResponseHandler.get().handleVisibleMessage(message) + messageRequestResponseHandler.get().handleVisibleMessage(null, message) // Handle group invite response if new closed group val threadRecipientAddress = context.threadAddress @@ -385,7 +395,7 @@ class ReceivedMessageHandler @Inject constructor( // Verify the incoming message length and truncate it if needed, before saving it to the db val maxChars = proStatusManager.getIncomingMessageMaxLength(message) - val messageText = message.text?.take(maxChars) // truncate to max char limit for this message + val messageText = message.text?.let { Util.truncateCodepoints(it, maxChars) } // truncate to max char limit for this message message.text = messageText message.hasMention = listOfNotNull(userPublicKey, context.userBlindedKey) .any { key -> @@ -438,14 +448,7 @@ class ReceivedMessageHandler @Inject constructor( // - must be done after the message is persisted) // - must be done after neccessary contact is created if (runProfileUpdate && senderAddress is Address.WithAccountId) { - val updates = ProfileUpdateHandler.Updates.create( - name = message.profile?.displayName, - picUrl = message.profile?.profilePictureURL, - picKey = message.profile?.profileKey, - blocksCommunityMessageRequests = message.blocksMessageRequests, - proStatus = null, - profileUpdateTime = message.profile?.profileUpdated, - ) + val updates = ProfileUpdateHandler.Updates.create(proto) if (updates != null) { profileUpdateHandler.get().handleProfileUpdate( @@ -484,7 +487,7 @@ class ReceivedMessageHandler @Inject constructor( return null } - private fun handleGroupUpdated(message: GroupUpdated, closedGroup: AccountId?) { + private fun handleGroupUpdated(message: GroupUpdated, closedGroup: AccountId?, proto: SignalServiceProtos.Content) { val inner = message.inner if (closedGroup == null && !inner.hasInviteMessage() && !inner.hasPromoteMessage()) { @@ -492,14 +495,7 @@ class ReceivedMessageHandler @Inject constructor( } // Update profile if needed - ProfileUpdateHandler.Updates.create( - name = message.profile?.displayName, - picUrl = message.profile?.profilePictureURL, - picKey = message.profile?.profileKey, - blocksCommunityMessageRequests = null, - proStatus = null, - profileUpdateTime = null - )?.let { updates -> + ProfileUpdateHandler.Updates.create(proto)?.let { updates -> profileUpdateHandler.get().handleProfileUpdate( senderId = AccountId(message.sender!!), updates = updates, @@ -508,9 +504,9 @@ class ReceivedMessageHandler @Inject constructor( } when { - inner.hasInviteMessage() -> handleNewLibSessionClosedGroupMessage(message) + inner.hasInviteMessage() -> handleNewLibSessionClosedGroupMessage(message, proto) inner.hasInviteResponse() -> handleInviteResponse(message, closedGroup!!) - inner.hasPromoteMessage() -> handlePromotionMessage(message) + inner.hasPromoteMessage() -> handlePromotionMessage(message, proto) inner.hasInfoChangeMessage() -> handleGroupInfoChange(message, closedGroup!!) inner.hasMemberChangeMessage() -> handleMemberChange(message, closedGroup!!) inner.hasMemberLeftMessage() -> handleMemberLeft(message, closedGroup!!) @@ -589,7 +585,7 @@ class ReceivedMessageHandler @Inject constructor( groupManagerV2.handleGroupInfoChange(message, closedGroup) } - private fun handlePromotionMessage(message: GroupUpdated) { + private fun handlePromotionMessage(message: GroupUpdated, proto: SignalServiceProtos.Content) { val promotion = message.inner.promoteMessage val seed = promotion.groupIdentitySeed.toByteArray() val sender = message.sender!! @@ -602,7 +598,9 @@ class ReceivedMessageHandler @Inject constructor( groupName = promotion.name, adminKeySeed = seed, promoter = adminId, - promoterName = message.profile?.displayName, + promoterName = if (proto.hasDataMessage() && proto.dataMessage.hasProfile() && proto.dataMessage.profile.hasDisplayName()) + proto.dataMessage.profile.displayName + else null, promoteMessageHash = message.serverHash!!, promoteMessageTimestamp = message.sentTimestamp!!, ) @@ -625,7 +623,7 @@ class ReceivedMessageHandler @Inject constructor( } } - private fun handleNewLibSessionClosedGroupMessage(message: GroupUpdated) { + private fun handleNewLibSessionClosedGroupMessage(message: GroupUpdated, proto: SignalServiceProtos.Content) { val storage = storage val ourUserId = storage.getUserPublicKey()!! val invite = message.inner.inviteMessage @@ -646,7 +644,9 @@ class ReceivedMessageHandler @Inject constructor( groupName = invite.name, authData = invite.memberAuthData.toByteArray(), inviter = adminId, - inviterName = message.profile?.displayName, + inviterName = if (proto.hasDataMessage() && proto.dataMessage.hasProfile() && proto.dataMessage.profile.hasDisplayName()) + proto.dataMessage.profile.displayName + else null, inviteMessageHash = message.serverHash!!, inviteMessageTimestamp = message.sentTimestamp!!, ) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt new file mode 100644 index 0000000000..c4137655d1 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt @@ -0,0 +1,612 @@ +package org.session.libsession.messaging.sending_receiving + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import network.loki.messenger.R +import network.loki.messenger.libsession_util.PRIORITY_HIDDEN +import network.loki.messenger.libsession_util.util.BaseCommunityInfo +import network.loki.messenger.libsession_util.util.BlindKeyAPI +import network.loki.messenger.libsession_util.util.KeyPair +import okio.withLock +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.userAuth +import org.session.libsession.messaging.messages.Message +import org.session.libsession.messaging.messages.Message.Companion.senderOrSync +import org.session.libsession.messaging.messages.control.CallMessage +import org.session.libsession.messaging.messages.control.DataExtractionNotification +import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.messages.control.GroupUpdated +import org.session.libsession.messaging.messages.control.MessageRequestResponse +import org.session.libsession.messaging.messages.control.ReadReceipt +import org.session.libsession.messaging.messages.control.TypingIndicator +import org.session.libsession.messaging.messages.control.UnsendRequest +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage +import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier +import org.session.libsession.messaging.utilities.WebRtcUtils +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.toAddress +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID +import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.UserConfigType +import org.session.libsession.utilities.recipients.MessageType +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.getType +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.BlindMappingRepository +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.sskenvironment.ReadReceiptManager +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.locks.ReentrantLock +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +class ReceivedMessageProcessor @Inject constructor( + @param:ApplicationContext private val context: Context, + private val recipientRepository: RecipientRepository, + private val storage: Storage, + private val configFactory: ConfigFactoryProtocol, + private val threadDatabase: ThreadDatabase, + private val readReceiptManager: Provider, + private val typingIndicators: Provider, + private val prefs: TextSecurePreferences, + private val groupMessageHandler: Provider, + private val messageExpirationManager: Provider, + private val messageDataProvider: MessageDataProvider, + @param:ManagerScope private val scope: CoroutineScope, + private val notificationManager: MessageNotifier, + private val messageRequestResponseHandler: Provider, + private val visibleMessageHandler: Provider, + private val blindMappingRepository: BlindMappingRepository, + private val messageParser: MessageParser, +) { + private val threadMutexes = ConcurrentHashMap() + + private inline fun withThreadLock( + threadAddress: Address.Conversable, + block: () -> T + ) { + threadMutexes.getOrPut(threadAddress) { ReentrantLock() }.withLock { + block() + } + } + + + /** + * Start a message processing session, ensuring that thread updates and notifications are handled + * once the whole processing is complete. + * + * Note: the context passed to the block is not thread-safe, so it should not be shared between threads. + */ + fun startProcessing(debugName: String, block: (MessageProcessingContext) -> T): T { + val context = MessageProcessingContext() + val start = System.currentTimeMillis() + try { + return block(context) + } finally { + for (threadId in context.threadIDs.values) { + if (context.maxOutgoingMessageTimestamp > 0L && + context.maxOutgoingMessageTimestamp > storage.getLastSeen(threadId) + ) { + storage.markConversationAsRead( + threadId, + context.maxOutgoingMessageTimestamp, + force = true + ) + } + + storage.updateThread(threadId, true) + notificationManager.updateNotification(this.context, threadId) + } + + // Handle pending community reactions + context.pendingCommunityReactions?.let { reactions -> + storage.addReactions(reactions, replaceAll = true, notifyUnread = false) + reactions.clear() + } + + Log.d(TAG, "Processed messages for $debugName in ${System.currentTimeMillis() - start}ms") + } + } + + fun processSwarmMessage( + context: MessageProcessingContext, + threadAddress: Address.Conversable, + message: Message, + proto: SignalServiceProtos.Content, + ) = withThreadLock(threadAddress) { + // The logic to check if the message should be discarded due to being from a hidden contact. + if (threadAddress is Address.Standard && + message.sentTimestamp != null && + shouldDiscardForHiddenContact( + ctx = context, + messageTimestamp = message.sentTimestamp!!, + threadAddress = threadAddress + ) + ) { + log { "Dropping message from hidden contact ${threadAddress.debugString}" } + return@withThreadLock + } + + // Get or create thread ID, if we aren't allowed to create it, and it doesn't exist, drop the message + val threadId = context.threadIDs[threadAddress] ?: if (shouldCreateThread(message)) { + threadDatabase.getOrCreateThreadIdFor(threadAddress) + .also { context.threadIDs[threadAddress] = it } + } else { + threadDatabase.getThreadIdIfExistsFor(threadAddress) + .also { id -> + if (id == -1L) { + log { "Dropping message for non-existing thread ${threadAddress.debugString}" } + return@withThreadLock + } else { + context.threadIDs[threadAddress] = id + } + } + } + + when (message) { + is ReadReceipt -> handleReadReceipt(message) + is TypingIndicator -> handleTypingIndicator(message) + is GroupUpdated -> groupMessageHandler.get().handleGroupUpdated( + message = message, + groupId = (threadAddress as? Address.Group)?.accountId, + proto = proto + ) + + is ExpirationTimerUpdate -> { + // For groupsv2, there are dedicated mechanisms for handling expiration timers, and + // we want to avoid the 1-to-1 message format which is unauthenticated in a group settings. + if (threadAddress is Address.Group) { + Log.d("MessageReceiver", "Ignoring expiration timer update for closed group") + } // also ignore it for communities since they do not support disappearing messages + else if (threadAddress is Address.Community) { + Log.d("MessageReceiver", "Ignoring expiration timer update for communities") + } else { + handleExpirationTimerUpdate(message) + } + } + + is DataExtractionNotification -> handleDataExtractionNotification(message) + is UnsendRequest -> handleUnsendRequest(message) + is MessageRequestResponse -> messageRequestResponseHandler.get() + .handleExplicitRequestResponseMessage(context, message, proto) + + is VisibleMessage -> { + if (message.isSenderSelf && + message.sentTimestamp != null && + message.sentTimestamp!! > context.maxOutgoingMessageTimestamp + ) { + context.maxOutgoingMessageTimestamp = message.sentTimestamp!! + } + + visibleMessageHandler.get().handleVisibleMessage( + ctx = context, + message = message, + threadId = threadId, + threadAddress = threadAddress, + proto = proto, + runThreadUpdate = false, + runProfileUpdate = true, + ) + } + + is CallMessage -> handleCallMessage(message) + } + + } + + fun processCommunityInboxMessage( + context: MessageProcessingContext, + communityServerUrl: String, + communityServerPubKeyHex: String, + message: OpenGroupApi.DirectMessage + ) { + val (message, proto) = messageParser.parseCommunityDirectMessage( + msg = message, + currentUserId = context.currentUserId, + currentUserEd25519PrivKey = context.currentUserEd25519KeyPair.secretKey.data, + currentUserBlindedIDs = context.getCurrentUserBlindedIDsByServer(communityServerUrl), + communityServerPubKeyHex = communityServerPubKeyHex, + ) + + val threadAddress = message.senderOrSync.toAddress() as Address.Conversable + + withThreadLock(threadAddress) { + processSwarmMessage( + context = context, + threadAddress = threadAddress, + message = message, + proto = proto + ) + } + } + + fun processCommunityOutboxMessage( + context: MessageProcessingContext, + communityServerUrl: String, + communityServerPubKeyHex: String, + msg: OpenGroupApi.DirectMessage + ) { + val (message, proto) = messageParser.parseCommunityDirectMessage( + msg = msg, + currentUserId = context.currentUserId, + currentUserEd25519PrivKey = context.currentUserEd25519KeyPair.secretKey.data, + currentUserBlindedIDs = context.getCurrentUserBlindedIDsByServer(communityServerUrl), + communityServerPubKeyHex = communityServerPubKeyHex, + ) + + val threadAddress = Address.CommunityBlindedId( + serverUrl = communityServerUrl, + blindedId = Address.Blinded(AccountId(msg.recipient)) + ) + + withThreadLock(threadAddress) { + processSwarmMessage( + context = context, + threadAddress = threadAddress, + message = message, + proto = proto + ) + } + } + + fun processCommunityMessage( + context: MessageProcessingContext, + threadAddress: Address.Community, + message: OpenGroupApi.Message, + ) = withThreadLock(threadAddress) { + var messageId = messageParser.parseCommunityMessage( + msg = message, + currentUserId = context.currentUserId, + currentUserBlindedIDs = context.getCurrentUserBlindedIDsByThread(threadAddress) + )?.let { (msg, proto) -> + processSwarmMessage( + context = context, + threadAddress = threadAddress, + message = msg, + proto = proto + ) + + msg.id + } + + // For community, we have a different way of handling reaction, this is outside of + // the normal enveloped message (even though enveloped message can also contain reaction, + // it's not used by anyone at the moment). + if (messageId == null) { + Log.d(TAG, "Handling reactions only message for community ${threadAddress.debugString}") + messageId = requireNotNull( + messageDataProvider.getMessageID( + serverId = message.id, + threadId = requireNotNull(storage.getThreadId(threadAddress)) { + "No thread ID for community ${threadAddress.debugString}" + } + )) { + "No message persisted for community message ${message.id}" + } + } + + val messageServerId = message.id.toString() + val reactions = mutableListOf() + + for ((emoji, reaction) in message.reactions.orEmpty()) { + // We only really want up to 5 reactors per reaction to avoid excessive database load + // Among the 5 reactors, we must include ourselves if we reacted to this message + val otherReactorsToAdd = if (reaction.you) { + reactions += ReactionRecord( + messageId = messageId, + author = context.currentUserPublicKey, + emoji = emoji, + serverId = messageServerId, + count = reaction.count, + sortId = 0, + ) + + val myBlindedIDs = context.getCurrentUserBlindedIDsByThread(threadAddress) + + reaction.reactors + .asSequence() + .filterNot { reactor -> reactor == context.currentUserPublicKey || myBlindedIDs.any { it.hexString == reactor } } + .take(4) + } else { + reaction.reactors + .asSequence() + .take(5) + } + + + for (reactor in otherReactorsToAdd) { + reactions += ReactionRecord( + messageId = messageId, + author = reactor, + emoji = emoji, + serverId = messageServerId, + count = reaction.count, + sortId = reaction.index, + ) + } + } + + context.setCommunityMessageReactions(messageId, reactions) + } + + private fun handleReadReceipt(message: ReadReceipt) { + readReceiptManager.get().processReadReceipts( + message.sender!!, + message.timestamps!!, + message.receivedTimestamp!! + ) + } + + private fun handleTypingIndicator(message: TypingIndicator) { + when (message.kind!!) { + TypingIndicator.Kind.STARTED -> showTypingIndicatorIfNeeded(message.sender!!) + TypingIndicator.Kind.STOPPED -> hideTypingIndicatorIfNeeded(message.sender!!) + } + } + + private fun showTypingIndicatorIfNeeded(senderPublicKey: String) { + // We don't want to show other people's indicators if the toggle is off + if (!prefs.isTypingIndicatorsEnabled()) return + + val address = Address.fromSerialized(senderPublicKey) + val threadID = storage.getThreadId(address) ?: return + typingIndicators.get().didReceiveTypingStartedMessage(threadID, address, 1) + } + + private fun hideTypingIndicatorIfNeeded(senderPublicKey: String) { + val address = Address.fromSerialized(senderPublicKey) + val threadID = storage.getThreadId(address) ?: return + typingIndicators.get().didReceiveTypingStoppedMessage(threadID, address, 1, false) + } + + + /** + * Return true if this message should result in the creation of a thread. + */ + private fun shouldCreateThread(message: Message): Boolean { + return message is VisibleMessage + } + + private fun handleExpirationTimerUpdate(message: ExpirationTimerUpdate) { + messageExpirationManager.get().run { + insertExpirationTimerMessage(message) + onMessageReceived(message) + } + } + + private fun handleDataExtractionNotification(message: DataExtractionNotification) { + // We don't handle data extraction messages for groups (they shouldn't be sent, but just in case we filter them here too) + if (message.groupPublicKey != null) return + val senderPublicKey = message.sender!! + + val notification: DataExtractionNotificationInfoMessage = when (message.kind) { + is DataExtractionNotification.Kind.MediaSaved -> DataExtractionNotificationInfoMessage( + DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED + ) + + else -> return + } + storage.insertDataExtractionNotificationMessage( + senderPublicKey, + notification, + message.sentTimestamp!! + ) + } + + fun handleUnsendRequest(message: UnsendRequest): MessageId? { + val userPublicKey = storage.getUserPublicKey() + val userAuth = storage.userAuth ?: return null + val isLegacyGroupAdmin: Boolean = message.groupPublicKey?.let { key -> + var admin = false + val groupID = doubleEncodeGroupID(key) + val group = storage.getGroup(groupID) + if (group != null) { + admin = group.admins.map { it.toString() }.contains(message.sender) + } + admin + } ?: false + + // First we need to determine the validity of the UnsendRequest + // It is valid if: + val requestIsValid = + message.sender == message.author || // the sender is the author of the message + message.author == userPublicKey || // the sender is the current user + isLegacyGroupAdmin // sender is an admin of legacy group + + if (!requestIsValid) { + return null + } + + val timestamp = message.timestamp ?: return null + val author = message.author ?: return null + val messageToDelete = storage.getMessageByTimestamp(timestamp, author, false) ?: return null + val messageIdToDelete = messageToDelete.messageId + val messageType = messageToDelete.individualRecipient?.getType() + + // send a /delete rquest for 1on1 messages + if (messageType == MessageType.ONE_ON_ONE) { + messageDataProvider.getServerHashForMessage(messageIdToDelete)?.let { serverHash -> + scope.launch(Dispatchers.IO) { // using scope as we are slowly migrating to coroutines but we can't migrate everything at once + try { + SnodeAPI.deleteMessage(author, userAuth, listOf(serverHash)) + } catch (e: Exception) { + Log.e("Loki", "Failed to delete message", e) + } + } + } + } + + // the message is marked as deleted locally + // except for 'note to self' where the message is completely deleted + if (messageType == MessageType.NOTE_TO_SELF) { + messageDataProvider.deleteMessage(messageIdToDelete) + } else { + messageDataProvider.markMessageAsDeleted( + messageIdToDelete, + displayedMessage = context.getString(R.string.deleteMessageDeletedGlobally) + ) + } + + // delete reactions + storage.deleteReactions(messageToDelete.messageId) + + // update notification + if (!messageToDelete.isOutgoing) { + notificationManager.updateNotification(context) + } + + return messageIdToDelete + } + + private fun handleCallMessage(message: CallMessage) { + // TODO: refactor this out to persistence, just to help debug the flow and send/receive in synchronous testing + WebRtcUtils.SIGNAL_QUEUE.trySend(message) + } + + + /** + * Return true if the contact is marked as hidden for given message timestamp. + */ + private fun shouldDiscardForHiddenContact( + ctx: MessageProcessingContext, + messageTimestamp: Long, + threadAddress: Address.Standard + ): Boolean { + val hidden = configFactory.withUserConfigs { configs -> + configs.contacts.get(threadAddress.address)?.priority == PRIORITY_HIDDEN + } + + return hidden && + // the message's sentTimestamp is earlier than the sentTimestamp of the last config + messageTimestamp < ctx.contactConfigTimestamp + } + + /** + * A context object for processing received messages. This object is mostly used to store + * expensive data that are only valid for the duration of a processing session. + * + * It also tracks some deferred updates that should be applied once processing is complete, + * such as thread updates, reactions, and notifications. + */ + inner class MessageProcessingContext { + private var recipients: HashMap? = null + val threadIDs: HashMap = hashMapOf() + private var currentUserBlindedKeysByCommunityServer: HashMap>? = null + val currentUserId: AccountId = AccountId(requireNotNull(storage.getUserPublicKey()) { + "No current user available" + }) + + var maxOutgoingMessageTimestamp: Long = 0L + + val currentUserEd25519KeyPair: KeyPair by lazy(LazyThreadSafetyMode.NONE) { + requireNotNull(storage.getUserED25519KeyPair()) { + "No current user ED25519 key pair available" + } + } + + val currentUserPublicKey: String get() = currentUserId.hexString + + + val contactConfigTimestamp: Long by lazy(LazyThreadSafetyMode.NONE) { + configFactory.getConfigTimestamp(UserConfigType.CONTACTS, currentUserPublicKey) + } + + private var blindIDMappingCache: HashMap>>? = + null + + + var pendingCommunityReactions: HashMap>? = null + private set + + + fun getBlindIDMapping(address: Address.Standard): List> { + val cache = blindIDMappingCache + ?: hashMapOf>>().also { + blindIDMappingCache = it + } + + return cache.getOrPut(address) { + blindMappingRepository.calculateReverseMappings(address) + } + } + + + fun getThreadRecipient(threadAddress: Address.Conversable): Recipient { + val cache = recipients ?: hashMapOf().also { + recipients = it + } + + return cache.getOrPut(threadAddress) { + recipientRepository.getRecipientSync(threadAddress) + } + } + + fun getCurrentUserBlindedIDsByServer(serverUrl: String): List { + val serverPubKey = requireNotNull(storage.getOpenGroupPublicKey(serverUrl)) { + "No open group public key found" + } + + val cache = + currentUserBlindedKeysByCommunityServer ?: hashMapOf>().also { + currentUserBlindedKeysByCommunityServer = it + } + + return cache.getOrPut(serverUrl) { + BlindKeyAPI.blind15Ids( + sessionId = currentUserPublicKey, + serverPubKey = serverPubKey + ).map(::AccountId) + AccountId( + BlindKeyAPI.blind25Id( + sessionId = currentUserPublicKey, + serverPubKey = serverPubKey + ) + ) + } + } + + + fun getCurrentUserBlindedIDsByThread(address: Address.Conversable): List { + if (address !is Address.Community) return emptyList() + return getCurrentUserBlindedIDsByServer(address.serverUrl) + } + + + fun setCommunityMessageReactions(messageId: MessageId, reactions: List) { + val reactionsMap = pendingCommunityReactions + ?: hashMapOf>().also { + pendingCommunityReactions = it + } + + reactionsMap[messageId] = reactions + } + } + + companion object { + private const val TAG = "ReceivedMessageProcessor" + + private const val DEBUG_MESSAGE_PROCESSING = true + + private inline fun log(message: () -> String) { + if (DEBUG_MESSAGE_PROCESSING) { + Log.d(TAG, message()) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt new file mode 100644 index 0000000000..57ea0d6182 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt @@ -0,0 +1,255 @@ +package org.session.libsession.messaging.sending_receiving + +import android.text.TextUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import network.loki.messenger.libsession_util.PRIORITY_HIDDEN +import network.loki.messenger.libsession_util.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.util.BaseCommunityInfo +import network.loki.messenger.libsession_util.util.ExpiryMode +import network.loki.messenger.libsession_util.util.Util +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.messaging.jobs.AttachmentDownloadJob +import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.messages.ProfileUpdateHandler +import org.session.libsession.messaging.messages.visible.Attachment +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment +import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview +import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.toAddress +import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.isGroupOrCommunity +import org.session.libsession.utilities.recipients.RecipientData +import org.session.libsession.utilities.updateContact +import org.session.libsession.utilities.upsertContact +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.pro.ProStatusManager +import javax.inject.Inject +import javax.inject.Provider + +class VisibleMessageHandler @Inject constructor( + private val storage: Storage, + private val messageRequestResponseHandler: MessageRequestResponseHandler, + @param:ManagerScope private val scope: CoroutineScope, + private val groupManagerV2: GroupManagerV2, + private val messageDataProvider: MessageDataProvider, + private val proStatusManager: ProStatusManager, + private val configFactory: ConfigFactory, + private val profileUpdateHandler: Provider, + private val attachmentDownloadJobFactory: AttachmentDownloadJob.Factory, + private val messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol, + private val typingIndicators: SSKEnvironment.TypingIndicatorsProtocol, +){ + fun handleVisibleMessage( + ctx: ReceivedMessageProcessor.MessageProcessingContext, + message: VisibleMessage, + threadId: Long, + threadAddress: Address.Conversable, + proto: SignalServiceProtos.Content, + runThreadUpdate: Boolean, + runProfileUpdate: Boolean, + ): MessageId? { + val senderAddress = message.sender!!.toAddress() + + messageRequestResponseHandler.handleVisibleMessage(ctx, message) + + // Handle group invite response if new closed group + if (threadAddress is Address.Group && senderAddress is Address.Standard) { + scope.launch { + try { + groupManagerV2 + .handleInviteResponse( + threadAddress.accountId, + senderAddress.accountId, + approved = true + ) + } catch (e: Exception) { + Log.e("Loki", "Failed to handle invite response", e) + } + } + } + // Parse quote if needed + var quoteModel: QuoteModel? = null + var quoteMessageBody: String? = null + if (message.quote != null && proto.dataMessage.hasQuote()) { + val quote = proto.dataMessage.quote + + var author = quote.author.toAddress() + + if (author is Address.WithAccountId && author.accountId in ctx.getCurrentUserBlindedIDsByThread(threadAddress)) { + author = Address.Standard(ctx.currentUserId) + } + + val messageInfo = messageDataProvider.getMessageForQuote(threadId, quote.id, author) + quoteMessageBody = messageInfo?.third + quoteModel = if (messageInfo != null) { + val attachments = if (messageInfo.second) messageDataProvider.getAttachmentsAndLinkPreviewFor(messageInfo.first) else ArrayList() + QuoteModel(quote.id, author,null,false, attachments) + } else { + QuoteModel(quote.id, author,null, true, PointerAttachment.forPointers(proto.dataMessage.quote.attachmentsList)) + } + } + // Parse link preview if needed + val linkPreviews: MutableList = mutableListOf() + if (message.linkPreview != null && proto.dataMessage.previewCount > 0) { + for (preview in proto.dataMessage.previewList) { + val thumbnail = PointerAttachment.forPointer(preview.image) + val url = Optional.fromNullable(preview.url) + val title = Optional.fromNullable(preview.title) + val hasContent = !TextUtils.isEmpty(title.or("")) || thumbnail.isPresent + if (hasContent) { + val linkPreview = LinkPreview(url.get(), title.or(""), thumbnail) + linkPreviews.add(linkPreview) + } else { + Log.w("Loki", "Discarding an invalid link preview. hasContent: $hasContent") + } + } + } + // Parse attachments if needed + val attachments = proto.dataMessage.attachmentsList.map(Attachment::fromProto).filter { it.isValid() } + + // Cancel any typing indicators if needed + cancelTypingIndicatorsIfNeeded(message.sender!!) + + // Parse reaction if needed + val threadIsGroup = threadAddress.isGroupOrCommunity + message.reaction?.let { reaction -> + if (reaction.react == true) { + reaction.serverId = message.openGroupServerMessageID?.toString() ?: message.serverHash.orEmpty() + reaction.dateSent = message.sentTimestamp ?: 0 + reaction.dateReceived = message.receivedTimestamp ?: 0 + storage.addReaction( + threadId = threadId, + reaction = reaction, + messageSender = senderAddress.address, + notifyUnread = !threadIsGroup + ) + } else { + storage.removeReaction( + emoji = reaction.emoji!!, + messageTimestamp = reaction.timestamp!!, + threadId = threadId, + author = senderAddress.address, + notifyUnread = threadIsGroup + ) + } + } ?: run { + // A user is mentioned if their public key is in the body of a message or one of their messages + // was quoted + + // Verify the incoming message length and truncate it if needed, before saving it to the db + val maxChars = proStatusManager.getIncomingMessageMaxLength(message) + val messageText = message.text?.let { Util.truncateCodepoints(it, maxChars) } // truncate to max char limit for this message + message.text = messageText + message.hasMention = (sequenceOf(ctx.currentUserPublicKey) + ctx.getCurrentUserBlindedIDsByThread(threadAddress).asSequence()) + .any { key -> + messageText?.contains("@$key") == true || key == (quoteModel?.author?.toString() ?: "") + } + + // Persist the message + message.threadID = threadId + + // clean up the message - For example we do not want any expiration data on messages for communities + if(message.openGroupServerMessageID != null){ + message.expiryMode = ExpiryMode.NONE + } + + val threadRecipient = ctx.getThreadRecipient(threadAddress) + val messageID = storage.persist( + threadRecipient = threadRecipient, + message = message, + quotes = quoteModel, + linkPreview = linkPreviews, + attachments = attachments, + runThreadUpdate = runThreadUpdate + ) ?: return null + + // If we have previously "hidden" the sender, we should flip the flag back to visible, + // and this should only be done only for 1:1 messages + if (senderAddress is Address.Standard && + senderAddress.address != ctx.currentUserPublicKey && + threadAddress is Address.Standard) { + val existingContact = + configFactory.withUserConfigs { it.contacts.get(senderAddress.accountId.hexString) } + + if (existingContact != null && existingContact.priority == PRIORITY_HIDDEN) { + Log.d(TAG, "Flipping thread for ${senderAddress.debugString} to visible") + configFactory.withMutableUserConfigs { configs -> + configs.contacts.updateContact(senderAddress) { + priority = PRIORITY_VISIBLE + } + } + } else if (existingContact == null || !existingContact.approvedMe) { + // If we don't have the contact, create a new one with approvedMe = true + Log.d(TAG, "Creating new contact for ${senderAddress.debugString} with approvedMe = true") + configFactory.withMutableUserConfigs { configs -> + configs.contacts.upsertContact(senderAddress) { + approvedMe = true + } + } + } + } + + // Update profile if needed: + // - must be done after the message is persisted) + // - must be done after neccessary contact is created + if (runProfileUpdate && senderAddress is Address.WithAccountId) { + val updates = ProfileUpdateHandler.Updates.create(proto) + + if (updates != null) { + profileUpdateHandler.get().handleProfileUpdate( + senderId = senderAddress.accountId, + updates = updates, + fromCommunity = (threadRecipient.data as? RecipientData.Community)?.let { data -> + BaseCommunityInfo(baseUrl = data.serverUrl, room = data.room, pubKeyHex = data.serverPubKey) + }, + ) + } + } + + // Parse & persist attachments + // Start attachment downloads if needed + if (messageID.mms && (threadRecipient.autoDownloadAttachments == true || senderAddress.address == ctx.currentUserPublicKey)) { + storage.getAttachmentsForMessage(messageID.id).iterator().forEach { attachment -> + attachment.attachmentId?.let { id -> + JobQueue.shared.add( + attachmentDownloadJobFactory.create( + attachmentID = id.rowId, + mmsMessageId = messageID.id + )) + } + } + } + message.openGroupServerMessageID?.let { + storage.setOpenGroupServerMessageID( + messageID = messageID, + serverID = it, + threadID = threadId + ) + } + message.id = messageID + messageExpirationManager.onMessageReceived(message) + return messageID + } + return null + } + + private fun cancelTypingIndicatorsIfNeeded(senderPublicKey: String) { + val address = Address.fromSerialized(senderPublicKey) + val threadID = storage.getThreadId(address) ?: return + typingIndicators.didReceiveIncomingMessage(threadID, address, 1) + } + + companion object { + private const val TAG = "VisibleMessageHandler" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt index 827731450d..4a0509049f 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt @@ -13,17 +13,13 @@ import org.session.libsession.snode.OnionResponse import org.session.libsession.snode.Version import org.session.libsession.snode.utilities.asyncPromise import org.session.libsession.snode.utilities.await -import org.session.libsession.utilities.Device import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.JsonUtil -import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.emptyPromise import org.session.libsignal.utilities.retryWithUniformInterval @SuppressLint("StaticFieldLeak") object PushRegistryV1 { - private val TAG = PushRegistryV1::class.java.name - val context = MessagingModuleConfiguration.shared.context private const val MAX_RETRY_COUNT = 4 @@ -32,55 +28,6 @@ object PushRegistryV1 { @Suppress("OPT_IN_USAGE") private val scope: CoroutineScope = GlobalScope - fun register( - device: Device, - isPushEnabled: Boolean = TextSecurePreferences.isPushEnabled(context), - publicKey: String? = TextSecurePreferences.getLocalNumber(context), - legacyGroupPublicKeys: Collection = MessagingModuleConfiguration.shared.storage.getAllLegacyGroupPublicKeys() - ): Promise<*, Exception> = scope.asyncPromise { - if (isPushEnabled) { - retryWithUniformInterval(maxRetryCount = MAX_RETRY_COUNT) { doRegister(publicKey, device, legacyGroupPublicKeys) } - } - } - - private suspend fun doRegister(publicKey: String?, device: Device, legacyGroupPublicKeys: Collection) { - Log.d(TAG, "doRegister() called") - - val token = MessagingModuleConfiguration.shared.tokenFetcher.fetch() - publicKey ?: return - - val parameters = mapOf( - "token" to token, - "pubKey" to publicKey, - "device" to device.value, - "legacyGroupPublicKeys" to legacyGroupPublicKeys - ) - - val url = "${server.url}/register_legacy_groups_only" - val body = JsonUtil.toJson(parameters).toRequestBody("application/json".toMediaType()) - val request = Request.Builder().url(url).post(body).build() - - sendOnionRequest(request).await().checkError() - Log.d(TAG, "registerV1 success") - } - - /** - * Unregister push notifications for 1-1 conversations as this is now done in FirebasePushManager. - */ - fun unregister(): Promise<*, Exception> = scope.asyncPromise { - Log.d(TAG, "unregisterV1 requested") - - retryWithUniformInterval(maxRetryCount = MAX_RETRY_COUNT) { - val token = MessagingModuleConfiguration.shared.tokenFetcher.fetch() - val parameters = mapOf("token" to token) - val url = "${server.url}/unregister" - val body = JsonUtil.toJson(parameters).toRequestBody("application/json".toMediaType()) - val request = Request.Builder().url(url).post(body).build() - sendOnionRequest(request).await().checkError() - Log.d(TAG, "unregisterV1 success") - } - } - // Legacy Closed Groups fun subscribeGroup( diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt index a6c56bb47c..e83caa4d04 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt @@ -1,7 +1,6 @@ package org.session.libsession.messaging.sending_receiving.pollers import com.fasterxml.jackson.core.type.TypeReference -import com.google.protobuf.ByteString import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -18,14 +17,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import org.session.libsession.database.StorageProtocol -import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.jobs.MessageReceiveParameters import org.session.libsession.messaging.jobs.OpenGroupDeleteJob import org.session.libsession.messaging.jobs.TrimThreadJob import org.session.libsession.messaging.messages.Message.Companion.senderOrSync -import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate -import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.Endpoint import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi.BatchRequest @@ -33,26 +28,19 @@ import org.session.libsession.messaging.open_groups.OpenGroupApi.BatchRequestInf import org.session.libsession.messaging.open_groups.OpenGroupApi.BatchResponse import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability import org.session.libsession.messaging.open_groups.OpenGroupApi.DirectMessage -import org.session.libsession.messaging.open_groups.OpenGroupApi.Message import org.session.libsession.messaging.open_groups.OpenGroupApi.getOrFetchServerCapabilities import org.session.libsession.messaging.open_groups.OpenGroupApi.parallelBatch -import org.session.libsession.messaging.open_groups.OpenGroupMessage -import org.session.libsession.messaging.sending_receiving.MessageReceiver -import org.session.libsession.messaging.sending_receiving.ReceivedMessageHandler +import org.session.libsession.messaging.sending_receiving.MessageParser +import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol -import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.HTTP.Verb.GET import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.database.BlindMappingRepository import org.thoughtcrime.securesms.database.CommunityDatabase -import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.util.AppVisibilityManager -import java.util.concurrent.TimeUnit private typealias PollRequestToken = Channel>> @@ -67,14 +55,12 @@ private typealias PollRequestToken = Channel>> class OpenGroupPoller @AssistedInject constructor( private val storage: StorageProtocol, private val appVisibilityManager: AppVisibilityManager, - private val blindMappingRepository: BlindMappingRepository, - private val receivedMessageHandler: ReceivedMessageHandler, - private val batchMessageJobFactory: BatchMessageReceiveJob.Factory, private val configFactory: ConfigFactoryProtocol, - private val threadDatabase: ThreadDatabase, private val trimThreadJobFactory: TrimThreadJob.Factory, private val openGroupDeleteJobFactory: OpenGroupDeleteJob.Factory, private val communityDatabase: CommunityDatabase, + private val receivedMessageProcessor: ReceivedMessageProcessor, + private val messageParser: MessageParser, @Assisted private val server: String, @Assisted private val scope: CoroutineScope, @Assisted private val pollerSemaphore: Semaphore, @@ -168,8 +154,6 @@ class OpenGroupPoller @AssistedInject constructor( return emptyList() } - val publicKey = allCommunities.first { it.community.baseUrl == server }.community.pubKeyHex - poll(rooms) .asSequence() .filterNot { it.body == null } @@ -179,16 +163,16 @@ class OpenGroupPoller @AssistedInject constructor( handleRoomPollInfo(Address.Community(server, response.endpoint.roomToken), response.body as Map<*, *>) } is Endpoint.RoomMessagesRecent -> { - handleMessages(server, response.endpoint.roomToken, response.body as List) + handleMessages(response.endpoint.roomToken, response.body as List) } is Endpoint.RoomMessagesSince -> { - handleMessages(server, response.endpoint.roomToken, response.body as List) + handleMessages(response.endpoint.roomToken, response.body as List) } is Endpoint.Inbox, is Endpoint.InboxSince -> { - handleDirectMessages(server, false, response.body as List) + handleInboxMessages( response.body as List) } is Endpoint.Outbox, is Endpoint.OutboxSince -> { - handleDirectMessages(server, true, response.body as List) + handleOutboxMessages( response.body as List) } else -> { /* We don't care about the result of any other calls (won't be polled for) */} } @@ -228,7 +212,7 @@ class OpenGroupPoller @AssistedInject constructor( path = "/room/$room/messages/recent?t=r&reactors=5" ), endpoint = Endpoint.RoomMessagesRecent(room), - responseType = object : TypeReference>(){} + responseType = object : TypeReference>(){} ) } else { BatchRequestInfo( @@ -237,7 +221,7 @@ class OpenGroupPoller @AssistedInject constructor( path = "/room/$room/messages/since/$lastMessageServerId?t=r&reactors=5" ), endpoint = Endpoint.RoomMessagesSince(room, lastMessageServerId), - responseType = object : TypeReference>(){} + responseType = object : TypeReference>(){} ) } ) @@ -294,136 +278,120 @@ class OpenGroupPoller @AssistedInject constructor( private fun handleMessages( - server: String, roomToken: String, messages: List ) { - val sortedMessages = messages.sortedBy { it.seqno } - sortedMessages.maxOfOrNull { it.seqno }?.let { seqNo -> - storage.setLastMessageServerID(roomToken, server, seqNo) + val (deletions, additions) = messages.partition { it.deleted } + + val threadAddress = Address.Community(serverUrl = server, room = roomToken) + // check thread still exists + val threadId = storage.getThreadId(threadAddress) ?: return + + if (additions.isNotEmpty()) { + receivedMessageProcessor.startProcessing("CommunityPoller(${threadAddress.debugString})") { ctx -> + for (msg in additions.sortedBy { it.seqno }) { + try { + // Set the last message server ID to each message as we process them, so that if processing fails halfway through, + // we don't re-process messages we've already handled. + storage.setLastMessageServerID(roomToken, server, msg.seqno) + + receivedMessageProcessor.processCommunityMessage( + context = ctx, + threadAddress = threadAddress, + message = msg, + ) + } catch (e: Exception) { + Log.e( + TAG, + "Error processing open group message ${msg.id} in ${threadAddress.debugString}", + e + ) + } + } + } + + JobQueue.shared.add(trimThreadJobFactory.create(threadId)) } - val (deletions, additions) = sortedMessages.partition { it.deleted } - handleNewMessages(server, roomToken, additions.map { - OpenGroupMessage( - serverID = it.id, - sender = it.sessionId, - sentTimestamp = (it.posted * 1000).toLong(), - base64EncodedData = it.data, - base64EncodedSignature = it.signature, - reactions = it.reactions + + if (deletions.isNotEmpty()) { + JobQueue.shared.add( + openGroupDeleteJobFactory.create( + messageServerIds = LongArray(deletions.size) { i -> deletions[i].id }, + threadId = threadId + ) ) - }) - handleDeletedMessages(server, roomToken, deletions.map { it.id }) + } } - private suspend fun handleDirectMessages( - server: String, - fromOutbox: Boolean, - messages: List + /** + * Handle messages that are sent to us directly. + */ + private fun handleInboxMessages( + messages: List ) { if (messages.isEmpty()) return - val serverPublicKey = storage.getOpenGroupPublicKey(server)!! - val sortedMessages = messages.sortedBy { it.id } - val lastMessageId = sortedMessages.last().id - if (fromOutbox) { - storage.setLastOutboxMessageId(server, lastMessageId) - } else { - storage.setLastInboxMessageId(server, lastMessageId) - } - sortedMessages.forEach { - val encodedMessage = Base64.decode(it.message) - val envelope = SignalServiceProtos.Envelope.newBuilder() - .setTimestampMs(TimeUnit.SECONDS.toMillis(it.postedAt)) - .setType(SignalServiceProtos.Envelope.Type.SESSION_MESSAGE) - .setContent(ByteString.copyFrom(encodedMessage)) - .setSource(it.sender) - .build() - try { - val (message, proto) = MessageReceiver.parse( - envelope.toByteArray(), - null, - fromOutbox, - if (fromOutbox) it.recipient else it.sender, - serverPublicKey, - emptySet() // this shouldn't be necessary as we are polling open groups here - ) - if (fromOutbox) { - val syncTarget = blindMappingRepository.getMapping( - serverUrl = server, - blindedAddress = Address.Blinded(AccountId(it.recipient)) - )?.accountId?.hexString ?: it.recipient - - if (message is VisibleMessage) { - message.syncTarget = syncTarget - } else if (message is ExpirationTimerUpdate) { - message.syncTarget = syncTarget - } - } - val threadAddress = when (val addr = message.senderOrSync.toAddress()) { - is Address.Blinded -> Address.CommunityBlindedId(serverUrl = server, blindedId = addr) - is Address.Conversable -> addr - else -> throw IllegalArgumentException("Unsupported address type: ${addr.debugString}") - } + val sorted = messages.sortedBy { it.postedAt } - val threadId = threadDatabase.getThreadIdIfExistsFor(threadAddress) - receivedMessageHandler.handle( - message = message, - proto = proto, - threadId = threadId, - threadAddress = threadAddress, - ) - } catch (e: Exception) { - Log.e(TAG, "Couldn't handle direct message", e) + val serverPubKeyHex = storage.getOpenGroupPublicKey(server) + ?: run { + Log.e(TAG, "No community server public key cannot process inbox messages") + return } - } - } - private fun handleNewMessages(server: String, roomToken: String, messages: List) { - val threadAddress = Address.Community(serverUrl = server, room = roomToken) - // check thread still exists - val threadId = storage.getThreadId(threadAddress) ?: return - val envelopes = mutableListOf?>>() - messages.sortedBy { it.serverID!! }.forEach { message -> - if (!message.base64EncodedData.isNullOrEmpty()) { - val envelope = SignalServiceProtos.Envelope.newBuilder() - .setType(SignalServiceProtos.Envelope.Type.SESSION_MESSAGE) - .setSource(message.sender!!) - .setSourceDevice(1) - .setContent(message.toProto().toByteString()) - .setTimestampMs(message.sentTimestamp) - .build() - envelopes.add(Triple( message.serverID, envelope, message.reactions)) + receivedMessageProcessor.startProcessing("CommunityInbox") { ctx -> + for (apiMessage in sorted) { + try { + storage.setLastInboxMessageId(server, sorted.last().id) + + receivedMessageProcessor.processCommunityInboxMessage( + context = ctx, + message = apiMessage, + communityServerUrl = server, + communityServerPubKeyHex = serverPubKeyHex, + ) + + } catch (e: Exception) { + Log.e(TAG, "Error processing inbox message", e) + } } } + } + + /** + * Handle messages that we have sent out to others. + */ + private fun handleOutboxMessages( + messages: List + ) { + if (messages.isEmpty()) return + val sorted = messages.sortedBy { it.postedAt } - envelopes.chunked(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { list -> - val parameters = list.map { (serverId, message, reactions) -> - MessageReceiveParameters(message.toByteArray(), openGroupMessageServerID = serverId, reactions = reactions) + val serverPubKeyHex = storage.getOpenGroupPublicKey(server) + ?: run { + Log.e(TAG, "No community server public key cannot process inbox messages") + return } - JobQueue.shared.add(batchMessageJobFactory.create( - parameters, - fromCommunity = threadAddress - )) - } - if (envelopes.isNotEmpty()) { - JobQueue.shared.add(trimThreadJobFactory.create(threadId)) - } - } + receivedMessageProcessor.startProcessing("CommunityOutbox") { ctx -> + for (apiMessage in sorted) { + try { + storage.setLastOutboxMessageId(server, sorted.last().id) - private fun handleDeletedMessages(server: String, roomToken: String, serverIds: List) { - val threadID = storage.getThreadId(Address.Community(serverUrl = server, room = roomToken)) ?: return + receivedMessageProcessor.processCommunityOutboxMessage( + context = ctx, + msg = apiMessage, + communityServerUrl = server, + communityServerPubKeyHex = serverPubKeyHex, + ) - if (serverIds.isNotEmpty()) { - JobQueue.shared.add( - openGroupDeleteJobFactory.create( - messageServerIds = serverIds.toLongArray(), - threadId = threadID - ) - ) + } catch (e: Exception) { + Log.e(TAG, "Error processing outbox message", e) + } + } } } + sealed interface PollState { data class Idle(val lastPolled: Result>?) : PollState data object Polling : PollState diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt index 463e3509f7..373c5b8fe7 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt @@ -6,8 +6,6 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.scan @@ -21,6 +19,7 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.userConfigsChanged import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.util.castAwayType @@ -44,29 +43,23 @@ class OpenGroupPollerManager @Inject constructor( pollerFactory: OpenGroupPoller.Factory, configFactory: ConfigFactoryProtocol, preferences: TextSecurePreferences, + loginStateRepository: LoginStateRepository, @ManagerScope scope: CoroutineScope ) : OnAppStartupComponent { private val pollerSemaphore = Semaphore(3) val pollers: StateFlow> = - preferences.watchLocalNumber() - .map { it != null } - .distinctUntilChanged() - .flatMapLatest { loggedIn -> - if (loggedIn) { - configFactory - .userConfigsChanged(onlyConfigTypes = EnumSet.of(UserConfigType.USER_GROUPS)) - .castAwayType() - .onStart { emit(Unit) } - .map { - configFactory.withUserConfigs { configs -> - configs.userGroups.allCommunityInfo() - }.mapTo(hashSetOf()) { it.community.baseUrl } - } - } else { - flowOf(emptySet()) + loginStateRepository.flowWithLoggedInState { + configFactory + .userConfigsChanged(onlyConfigTypes = EnumSet.of(UserConfigType.USER_GROUPS)) + .castAwayType() + .onStart { emit(Unit) } + .map { + configFactory.withUserConfigs { configs -> + configs.userGroups.allCommunityInfo() + }.mapTo(hashSetOf()) { it.community.baseUrl } } - } + } .distinctUntilChanged() .scan(emptyMap()) { acc, value -> if (acc.keys == value) { diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt index 13dfeea206..160137f53a 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt @@ -27,21 +27,23 @@ import kotlinx.coroutines.supervisorScope import network.loki.messenger.libsession_util.Namespace import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.jobs.BatchMessageReceiveJob -import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.jobs.MessageReceiveParameters -import org.session.libsession.snode.RawResponse +import org.session.libsession.messaging.messages.Message.Companion.senderOrSync +import org.session.libsession.messaging.sending_receiving.MessageParser +import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeClock +import org.session.libsession.snode.model.RetrieveMessageResponse import org.session.libsession.snode.utilities.await +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigMessage import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsignal.database.LokiAPIDatabaseProtocol -import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.util.AppVisibilityManager import org.thoughtcrime.securesms.util.NetworkConnectivity import kotlin.time.Duration.Companion.days @@ -57,7 +59,10 @@ class Poller @AssistedInject constructor( private val preferences: TextSecurePreferences, private val appVisibilityManager: AppVisibilityManager, private val networkConnectivity: NetworkConnectivity, - private val batchMessageReceiveJobFactory: BatchMessageReceiveJob.Factory, + private val snodeClock: SnodeClock, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, + private val processor: ReceivedMessageProcessor, + private val messageParser: MessageParser, @Assisted scope: CoroutineScope ) { private val userPublicKey: String @@ -119,7 +124,7 @@ class Poller @AssistedInject constructor( // To migrate to multi part config, we'll need to fetch all the config messages so we // get the chance to process those multipart messages again... lokiApiDatabase.clearLastMessageHashesByNamespaces(*allConfigNamespaces) - lokiApiDatabase.clearReceivedMessageHashValuesByNamespaces(*allConfigNamespaces) + receivedMessageHashDatabase.removeAllByNamespaces(*allConfigNamespaces) preferences.migratedToMultiPartConfig = true } @@ -208,60 +213,91 @@ class Poller @AssistedInject constructor( } } - private fun processPersonalMessages(snode: Snode, rawMessages: RawResponse) { - val messages = SnodeAPI.parseRawMessagesResponse(rawMessages, snode, userPublicKey) - val parameters = messages.map { (envelope, serverHash) -> - MessageReceiveParameters(envelope.toByteArray(), serverHash = serverHash) + private fun processPersonalMessages(messages: List) { + if (messages.isEmpty()) { + Log.d(TAG, "No personal messages to process") + return } - parameters.chunked(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { chunk -> - JobQueue.shared.add(batchMessageReceiveJobFactory.create( - messages = chunk, - fromCommunity = null - )) - } - } - private fun processConfig(snode: Snode, rawMessages: RawResponse, forConfig: UserConfigType) { - Log.d(TAG, "Received ${rawMessages.size} messages for $forConfig") - val messages = rawMessages["messages"] as? List<*> - val namespace = forConfig.namespace - val processed = if (!messages.isNullOrEmpty()) { - SnodeAPI.updateLastMessageHashValueIfPossible(snode, userPublicKey, messages, namespace) - SnodeAPI.removeDuplicates( - publicKey = userPublicKey, - messages = messages, - messageHashGetter = { (it as? Map<*, *>)?.get("hash") as? String }, - namespace = namespace, - updateStoredHashes = true - ).mapNotNull { rawMessageAsJSON -> - rawMessageAsJSON as Map<*, *> // removeDuplicates should have ensured this is always a map - val hashValue = rawMessageAsJSON["hash"] as? String ?: return@mapNotNull null - val b64EncodedBody = rawMessageAsJSON["data"] as? String ?: return@mapNotNull null - val timestamp = rawMessageAsJSON["t"] as? Long ?: SnodeAPI.nowWithOffset - val body = Base64.decode(b64EncodedBody) - ConfigMessage(data = body, hash = hashValue, timestamp = timestamp) + Log.d(TAG, "Received ${messages.size} personal messages from snode") + + processor.startProcessing("Poller") { ctx -> + for (message in messages) { + if (receivedMessageHashDatabase.checkOrUpdateDuplicateState( + swarmPublicKey = userPublicKey, + namespace = Namespace.DEFAULT(), + hash = message.hash + )) { + Log.d(TAG, "Skipping duplicated message ${message.hash}") + continue + } + + try { + val (message, proto) = messageParser.parse1o1Message( + data = message.data, + serverHash = message.hash, + currentUserEd25519PrivKey = ctx.currentUserEd25519KeyPair.secretKey.data, + currentUserId = ctx.currentUserId + ) + + processor.processSwarmMessage( + threadAddress = message.senderOrSync.toAddress() as Address.Conversable, + message = message, + proto = proto, + context = ctx, + ) + } catch (ec: Exception) { + Log.e( + TAG, + "Error while processing personal message with hash ${message.hash}", + ec + ) + } } - } else emptyList() + } + } - Log.d(TAG, "About to process ${processed.size} messages for $forConfig") + private fun processConfig(messages: List, forConfig: UserConfigType) { + if (messages.isEmpty()) { + Log.d(TAG, "No messages to process for $forConfig") + return + } - if (processed.isEmpty()) return + val newMessages = messages + .asSequence() + .filterNot { msg -> + receivedMessageHashDatabase.checkOrUpdateDuplicateState( + swarmPublicKey = userPublicKey, + namespace = forConfig.namespace, + hash = msg.hash + ) + } + .map { m-> + ConfigMessage( + data = m.data, + hash = m.hash, + timestamp = m.timestamp.toEpochMilli() + ) + } + .toList() - try { - configFactory.mergeUserConfigs( - userConfigType = forConfig, - messages = processed, - ) - } catch (e: Exception) { - Log.e(TAG, "Error while merging user configs", e) + if (newMessages.isNotEmpty()) { + try { + configFactory.mergeUserConfigs( + userConfigType = forConfig, + messages = newMessages + ) + } catch (e: Exception) { + Log.e(TAG, "Error while merging user configs for $forConfig", e) + } } - Log.d(TAG, "Completed processing messages for $forConfig") + Log.d(TAG, "Processed ${newMessages.size} new messages for config $forConfig") } private suspend fun poll(snode: Snode, pollOnlyUserProfileConfig: Boolean) = supervisorScope { - val userAuth = requireNotNull(MessagingModuleConfiguration.shared.storage.userAuth) + val userAuth = requireNotNull(storage.userAuth) // Get messages call wrapped in an async val fetchMessageTask = if (!pollOnlyUserProfileConfig) { @@ -280,7 +316,7 @@ class Poller @AssistedInject constructor( snode = snode, publicKey = userPublicKey, request = request, - responseType = Map::class.java + responseType = RetrieveMessageResponse.serializer() ) } } @@ -314,7 +350,12 @@ class Poller @AssistedInject constructor( this.async { type to runCatching { - SnodeAPI.sendBatchRequest(snode, userPublicKey, request, Map::class.java) + SnodeAPI.sendBatchRequest( + snode = snode, + publicKey = userPublicKey, + request = request, + responseType = RetrieveMessageResponse.serializer() + ) } } } @@ -329,7 +370,7 @@ class Poller @AssistedInject constructor( SnodeAPI.buildAuthenticatedAlterTtlBatchRequest( messageHashes = hashesToExtend.toList(), auth = userAuth, - newExpiry = SnodeAPI.nowWithOffset + 14.days.inWholeMilliseconds, + newExpiry = snodeClock.currentTimeMills() + 14.days.inWholeMilliseconds, extend = true ) ) @@ -350,7 +391,18 @@ class Poller @AssistedInject constructor( continue } - processConfig(snode, result.getOrThrow(), configType) + val messages = result.getOrThrow().messages + processConfig(messages = messages, forConfig = configType) + + if (messages.isNotEmpty()) { + lokiApiDatabase.setLastMessageHashValue( + snode = snode, + publicKey = userPublicKey, + newValue = messages + .maxBy { it.timestamp }.hash, + namespace = configType.namespace + ) + } } // Process the messages if we requested them @@ -359,7 +411,17 @@ class Poller @AssistedInject constructor( if (result.isFailure) { Log.e(TAG, "Error while fetching messages", result.exceptionOrNull()) } else { - processPersonalMessages(snode, result.getOrThrow()) + val messages = result.getOrThrow().messages + processPersonalMessages(messages) + + messages.maxByOrNull { it.timestamp }?.let { newest -> + lokiApiDatabase.setLastMessageHashValue( + snode = snode, + publicKey = userPublicKey, + newValue = newest.hash, + namespace = Namespace.DEFAULT() + ) + } } } } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/PollerManager.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/PollerManager.kt index aebfe25349..7ca2d936de 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/PollerManager.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/PollerManager.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import javax.inject.Inject import javax.inject.Singleton @@ -26,12 +26,13 @@ import javax.inject.Singleton */ @Singleton class PollerManager @Inject constructor( - prefers: TextSecurePreferences, provider: Poller.Factory, + loginStateRepository: LoginStateRepository, ) : OnAppStartupComponent { @OptIn(DelicateCoroutinesApi::class) private val currentPoller: StateFlow = channelFlow { - prefers.watchLocalNumber() + loginStateRepository + .loggedInState .map { it != null } .distinctUntilChanged() .collectLatest { loggedIn -> diff --git a/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt b/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt index bab6cd0c1a..3fa4a53f15 100644 --- a/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt +++ b/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt @@ -1,83 +1,14 @@ package org.session.libsession.messaging.utilities -import com.google.protobuf.ByteString import org.session.libsignal.protos.SignalServiceProtos.Envelope import org.session.libsignal.protos.WebSocketProtos.WebSocketMessage -import org.session.libsignal.protos.WebSocketProtos.WebSocketRequestMessage -import org.session.libsignal.utilities.Log -import java.security.SecureRandom object MessageWrapper { - // region Types - sealed class Error(val description: String) : Exception(description) { - object FailedToWrapData : Error("Failed to wrap data.") - object FailedToWrapMessageInEnvelope : Error("Failed to wrap message in envelope.") - object FailedToWrapEnvelopeInWebSocketMessage : Error("Failed to wrap envelope in web socket message.") - object FailedToUnwrapData : Error("Failed to unwrap data.") - } - // endregion - - // region Wrapping - /** - * Wraps `message` in a `SignalServiceProtos.Envelope` and then a `WebSocketProtos.WebSocketMessage` to match the desktop application. - */ - fun wrap(type: Envelope.Type, timestamp: Long, senderPublicKey: String, content: ByteArray): ByteArray { - try { - val envelope = createEnvelope(type, timestamp, senderPublicKey, content) - val webSocketMessage = createWebSocketMessage(envelope) - return webSocketMessage.toByteArray() - } catch (e: Exception) { - throw if (e is Error) e else Error.FailedToWrapData - } - } - - fun createEnvelope(type: Envelope.Type, timestamp: Long, senderPublicKey: String, content: ByteArray): Envelope { - try { - val builder = Envelope.newBuilder() - builder.type = type - builder.timestampMs = timestamp - builder.source = senderPublicKey - builder.sourceDevice = 1 - builder.content = ByteString.copyFrom(content) - return builder.build() - } catch (e: Exception) { - Log.d("Loki", "Failed to wrap message in envelope: ${e.message}.") - throw Error.FailedToWrapMessageInEnvelope - } - } - - private fun createWebSocketMessage(envelope: Envelope): WebSocketMessage { - try { - return WebSocketMessage.newBuilder().apply { - request = WebSocketRequestMessage.newBuilder().apply { - verb = "PUT" - path = "/api/v1/message" - id = SecureRandom().nextLong() - body = envelope.toByteString() - }.build() - type = WebSocketMessage.Type.REQUEST - }.build() - } catch (e: Exception) { - Log.d("MessageWrapper", "Failed to wrap envelope in web socket message: ${e.message}.") - throw Error.FailedToWrapEnvelopeInWebSocketMessage - } - } - // endregion - - // region Unwrapping - /** - * `data` shouldn't be base 64 encoded. - */ fun unwrap(data: ByteArray): Envelope { - try { - val webSocketMessage = WebSocketMessage.parseFrom(data) - val envelopeAsData = webSocketMessage.request.body - return Envelope.parseFrom(envelopeAsData) - } catch (e: Exception) { - Log.d("MessageWrapper", "Failed to unwrap data", e) - throw Error.FailedToUnwrapData - } + val webSocketMessage = WebSocketMessage.parseFrom(data) + val envelopeAsData = webSocketMessage.request.body + return Envelope.parseFrom(envelopeAsData) } // endregion } diff --git a/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt b/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt index 7b471a216c..b1a39c7f7a 100644 --- a/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -3,7 +3,6 @@ package org.session.libsession.snode import android.os.SystemClock -import com.fasterxml.jackson.databind.JsonNode import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -12,20 +11,20 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromStream import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.Hash import network.loki.messenger.libsession_util.SessionEncrypt import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.all import nl.komponents.kovenant.functional.bind import nl.komponents.kovenant.functional.map -import nl.komponents.kovenant.unwrap import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.utilities.MessageWrapper import org.session.libsession.snode.model.BatchResponse import org.session.libsession.snode.model.StoreMessageResponse import org.session.libsession.snode.utilities.asyncPromise @@ -37,17 +36,13 @@ import org.session.libsession.utilities.toByteArray import org.session.libsignal.crypto.secureRandom import org.session.libsignal.crypto.shuffledRandom import org.session.libsignal.database.LokiAPIDatabaseProtocol -import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 -import org.session.libsignal.utilities.Broadcaster import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.prettifiedDescription -import org.session.libsignal.utilities.retryIfNeeded import org.session.libsignal.utilities.retryWithUniformInterval import java.util.Locale import kotlin.collections.component1 @@ -86,7 +81,7 @@ object SnodeAPI { private val seedNodePort = 4443 private val seedNodePool = when (SnodeModule.shared.environment) { - Environment.DEV_NET -> setOf("http://192.168.1.223:1280") + Environment.DEV_NET -> setOf("http://sesh-net.local:1280") Environment.TEST_NET -> setOf("http://public.loki.foundation:38157") Environment.MAIN_NET -> setOf( "https://seed1.getsession.org:$seedNodePort", @@ -165,13 +160,18 @@ object SnodeAPI { method: Snode.Method, snode: Snode, parameters: Map, - responseClass: Class, + responseDeserializationStrategy: DeserializationStrategy, publicKey: String? = null, version: Version = Version.V3 ): Res = when { useOnionRequests -> { val resp = OnionRequestAPI.sendOnionRequest(method, parameters, snode, version, publicKey).await() - JsonUtil.fromJson(resp.body ?: throw Error.Generic, responseClass) + (resp.body ?: throw Error.Generic).inputStream().use { inputStream -> + MessagingModuleConfiguration.shared.json.decodeFromStream( + deserializer = responseDeserializationStrategy, + stream = inputStream + ) + } } else -> HTTP.execute( @@ -182,7 +182,10 @@ object SnodeAPI { this["params"] = parameters } ).toString().let { - JsonUtil.fromJson(it, responseClass) + MessagingModuleConfiguration.shared.json.decodeFromString( + deserializer = responseDeserializationStrategy, + string = it + ) } } @@ -355,49 +358,6 @@ object SnodeAPI { } } - /** - * Retrieve messages from the swarm. - * - * @param snode The swarm service where you want to retrieve messages from. It can be a swarm for a specific user or a group. Call [getSingleTargetSnode] to get a swarm node. - * @param auth The authentication data required to retrieve messages. This can be a user or group authentication data. - * @param namespace The namespace of the messages you want to retrieve. Default is 0. - */ - fun getRawMessages( - snode: Snode, - auth: SwarmAuth, - namespace: Int = 0 - ): RawResponsePromise { - val parameters = buildAuthenticatedParameters( - namespace = namespace, - auth = auth, - verificationData = { ns, t -> "${Snode.Method.Retrieve.rawValue}$ns$t" } - ) { - put( - "last_hash", - database.getLastMessageHashValue(snode, auth.accountId.hexString, namespace).orEmpty() - ) - } - - // Make the request - return invoke(Snode.Method.Retrieve, snode, parameters, auth.accountId.hexString) - } - - fun getUnauthenticatedRawMessages( - snode: Snode, - publicKey: String, - namespace: Int = 0 - ): RawResponsePromise { - val parameters = buildMap { - put("last_hash", database.getLastMessageHashValue(snode, publicKey, namespace).orEmpty()) - put("pubkey", publicKey) - if (namespace != 0) { - put("namespace", namespace) - } - } - - return invoke(Snode.Method.Retrieve, snode, parameters, publicKey) - } - fun buildAuthenticatedStoreBatchInfo( namespace: Int, message: SnodeMessage, @@ -541,40 +501,11 @@ object SnodeAPI { ) } - @Suppress("UNCHECKED_CAST") - fun getRawBatchResponse( - snode: Snode, - publicKey: String, - requests: List, - sequence: Boolean = false - ): RawResponsePromise { - val parameters = buildMap { this["requests"] = requests } - return invoke( - if (sequence) Snode.Method.Sequence else Snode.Method.Batch, - snode, - parameters, - publicKey - ).success { rawResponses -> - rawResponses[KEY_RESULTS].let { it as List } - .asSequence() - .filter { it[KEY_CODE] as? Int != 200 } - .forEach { response -> - Log.w("Loki", "response code was not 200") - handleSnodeError( - response[KEY_CODE] as? Int ?: 0, - response[KEY_BODY] as? Map<*, *>, - snode, - publicKey - ) - } - } - } - private data class RequestInfo( val snode: Snode, val publicKey: String, val request: SnodeBatchRequestInfo, - val responseType: Class<*>, + val responseType: DeserializationStrategy<*>, val callback: SendChannel>, val requestTime: Long = SystemClock.elapsedRealtime(), ) @@ -641,7 +572,8 @@ object SnodeAPI { throw BatchResponse.Error(resp) } - JsonUtil.fromJson(resp.body, req.responseType) + MessagingModuleConfiguration.shared.json.decodeFromJsonElement( + req.responseType, resp.body)!! } runCatching { @@ -664,7 +596,7 @@ object SnodeAPI { snode: Snode, publicKey: String, request: SnodeBatchRequestInfo, - responseType: Class, + responseType: DeserializationStrategy, ): T { val callback = Channel>(capacity = 1) @Suppress("UNCHECKED_CAST") @@ -689,8 +621,8 @@ object SnodeAPI { snode: Snode, publicKey: String, request: SnodeBatchRequestInfo, - ): JsonNode { - return sendBatchRequest(snode, publicKey, request, JsonNode::class.java) + ): JsonElement { + return sendBatchRequest(snode, publicKey, request, JsonElement.serializer()) } suspend fun getBatchResponse( @@ -703,7 +635,7 @@ object SnodeAPI { method = if (sequence) Snode.Method.Sequence else Snode.Method.Batch, snode = snode, parameters = mapOf("requests" to requests), - responseClass = BatchResponse::class.java, + responseDeserializationStrategy = BatchResponse.serializer(), publicKey = publicKey ).also { resp -> // If there's a unsuccessful response, go through specific logic to handle @@ -712,8 +644,8 @@ object SnodeAPI { if (firstError != null) { handleSnodeError( statusCode = firstError.code, - json = if (firstError.body.isObject) { - JsonUtil.fromJson(firstError.body, Map::class.java) + json = if (firstError.body is JsonObject) { + JsonUtil.fromJson(firstError.body.toString(), Map::class.java) } else { null }, @@ -724,30 +656,6 @@ object SnodeAPI { } } - fun getExpiries( - messageHashes: List, - auth: SwarmAuth, - ): RawResponsePromise { - val hashes = messageHashes.takeIf { it.size != 1 } - ?: (messageHashes + "///////////////////////////////////////////") // TODO remove this when bug is fixed on nodes. - return scope.retrySuspendAsPromise(maxRetryCount) { - val params = buildAuthenticatedParameters( - auth = auth, - namespace = null, - verificationData = { _, t -> buildString { - append(Snode.Method.GetExpiries.rawValue) - append(t) - hashes.forEach(this::append) - } }, - ) { - this["messages"] = hashes - } - - val snode = getSingleTargetSnode(auth.accountId.hexString).await() - invoke(Snode.Method.GetExpiries, snode, params, auth.accountId.hexString).await() - } - } - fun alterTtl( auth: SwarmAuth, messageHashes: List, @@ -790,12 +698,6 @@ object SnodeAPI { } } - fun getMessages(auth: SwarmAuth): MessageListPromise = scope.retrySuspendAsPromise(maxRetryCount) { - val snode = getSingleTargetSnode(auth.accountId.hexString).await() - val resp = getRawMessages(snode, auth).await() - parseRawMessagesResponse(resp, snode, auth.accountId.hexString) - } - fun getNetworkTime(snode: Snode): Promise, Exception> = invoke(Snode.Method.Info, snode, emptyMap()).map { rawResponse -> val timestamp = rawResponse["timestamp"] as? Long ?: -1 @@ -845,7 +747,7 @@ object SnodeAPI { params = params, namespace = namespace ), - responseType = StoreMessageResponse::class.java + responseType = StoreMessageResponse.serializer() ) } } @@ -959,89 +861,6 @@ object SnodeAPI { ) } - fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String, namespace: Int = 0, updateLatestHash: Boolean = true, updateStoredHashes: Boolean = true, decrypt: ((ByteArray) -> Pair?)? = null): List> = - (rawResponse["messages"] as? List<*>)?.let { messages -> - if (updateLatestHash) updateLastMessageHashValueIfPossible(snode, publicKey, messages, namespace) - removeDuplicates( - publicKey = publicKey, - messages = parseEnvelopes(messages, decrypt), - messageHashGetter = { it.second }, - namespace = namespace, - updateStoredHashes = updateStoredHashes - ) - } ?: listOf() - - fun updateLastMessageHashValueIfPossible(snode: Snode, publicKey: String, rawMessages: List<*>, namespace: Int) { - val lastMessageAsJSON = rawMessages.lastOrNull() as? Map<*, *> - val hashValue = lastMessageAsJSON?.get("hash") as? String - when { - hashValue != null -> database.setLastMessageHashValue(snode, publicKey, hashValue, namespace) - rawMessages.isNotEmpty() -> Log.d("Loki", "Failed to update last message hash value from: ${rawMessages.prettifiedDescription()}.") - } - } - - /** - * - * - * TODO Use a db transaction, synchronizing is sufficient for now because - * database#setReceivedMessageHashValues is only called here. - */ - @Synchronized - fun removeDuplicates( - publicKey: String, - messages: List, - messageHashGetter: (M) -> String?, - namespace: Int, - updateStoredHashes: Boolean - ): List { - val hashValues = database.getReceivedMessageHashValues(publicKey, namespace)?.toMutableSet() ?: mutableSetOf() - return messages - .filter { message -> - val hash = messageHashGetter(message) - if (hash == null) { - Log.d("Loki", "Missing hash value for message: ${message?.prettifiedDescription()}.") - return@filter false - } - - val isNew = hashValues.add(hash) - - if (!isNew) { - Log.d("Loki", "Duplicate message hash: $hash.") - } - - isNew - } - .also { - if (updateStoredHashes && it.isNotEmpty()) { - database.setReceivedMessageHashValues(publicKey, hashValues, namespace) - } - } - } - - private fun parseEnvelopes(rawMessages: List<*>, decrypt: ((ByteArray)->Pair?)?): List> { - return rawMessages.mapNotNull { rawMessage -> - val rawMessageAsJSON = rawMessage as? Map<*, *> - val base64EncodedData = rawMessageAsJSON?.get("data") as? String - val data = base64EncodedData?.let { Base64.decode(it) } - if (data != null) { - try { - if (decrypt != null) { - val (decrypted, sender) = decrypt(data)!! - val envelope = SignalServiceProtos.Envelope.parseFrom(decrypted).toBuilder() - envelope.source = sender.hexString - Pair(envelope.build(), rawMessageAsJSON["hash"] as? String) - } - else Pair(MessageWrapper.unwrap(data), rawMessageAsJSON["hash"] as? String) - } catch (e: Exception) { - Log.d("Loki", "Failed to unwrap data for message: ${rawMessage.prettifiedDescription()}.", e) - null - } - } else { - Log.d("Loki", "Failed to decode data for message: ${rawMessage?.prettifiedDescription()}.") - null - } - } - } @Suppress("UNCHECKED_CAST") private fun parseDeletions(userPublicKey: String, timestamp: Long, rawResponse: RawResponse): Map = @@ -1111,5 +930,4 @@ object SnodeAPI { // Type Aliases typealias RawResponse = Map<*, *> -typealias MessageListPromise = Promise>, Exception> typealias RawResponsePromise = Promise diff --git a/app/src/main/java/org/session/libsession/snode/SnodeClock.kt b/app/src/main/java/org/session/libsession/snode/SnodeClock.kt index 71382140cf..fc2e4939fe 100644 --- a/app/src/main/java/org/session/libsession/snode/SnodeClock.kt +++ b/app/src/main/java/org/session/libsession/snode/SnodeClock.kt @@ -87,6 +87,10 @@ class SnodeClock @Inject constructor( return currentTimeMills() / 1000 } + fun currentTime(): java.time.Instant { + return java.time.Instant.ofEpochMilli(currentTimeMills()) + } + private class Instant( val systemUptime: Long, val networkTime: Long, diff --git a/app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt b/app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt index 723abfc79f..d3fa2acd19 100644 --- a/app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt +++ b/app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt @@ -1,15 +1,15 @@ package org.session.libsession.snode.model -import com.fasterxml.jackson.annotation.JsonCreator -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.databind.JsonNode +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement -data class BatchResponse @JsonCreator constructor( - @param:JsonProperty("results") val results: List, -) { - data class Item @JsonCreator constructor( - @param:JsonProperty("code") val code: Int, - @param:JsonProperty("body") val body: JsonNode, +@Serializable + +data class BatchResponse(val results: List, ) { + @Serializable + data class Item( + val code: Int, + val body: JsonElement, ) { val isSuccessful: Boolean get() = code in 200..299 diff --git a/app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt b/app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt index b9173e1462..2c7b6f6d7e 100644 --- a/app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt +++ b/app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt @@ -1,41 +1,45 @@ package org.session.libsession.snode.model import android.util.Base64 -import com.fasterxml.jackson.annotation.JsonCreator -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.annotation.JsonDeserialize -import com.fasterxml.jackson.databind.util.StdConverter +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.session.libsession.utilities.serializable.InstantAsMillisSerializer +import java.time.Instant -data class StoreMessageResponse @JsonCreator constructor( - @JsonProperty("hash") val hash: String, - @JsonProperty("t") val timestamp: Long, +@Serializable +data class StoreMessageResponse( + val hash: String, + @Serializable(InstantAsMillisSerializer::class) + @SerialName("t") val timestamp: Instant, ) -class RetrieveMessageResponse @JsonCreator constructor( - @JsonProperty("messages") - // Apply converter to the element so that if one of the message fails to deserialize, it will - // be a null value instead of failing the whole list. - @JsonDeserialize(contentConverter = RetrieveMessageConverter::class) - val messages: List, +@Serializable +data class RetrieveMessageResponse( + val messages: List, ) { - class Message( + @Serializable + data class Message( val hash: String, - val timestamp: Long?, - val data: ByteArray, - ) -} -internal class RetrieveMessageConverter : StdConverter() { - override fun convert(value: JsonNode?): RetrieveMessageResponse.Message? { - value ?: return null + // Some messages use "t" as timestamp field + @Serializable(InstantAsMillisSerializer::class) + @SerialName("t") + private val t1: Instant? = null, - val hash = value.get("hash")?.asText()?.takeIf { it.isNotEmpty() } ?: return null - val timestamp = value.get("t")?.asLong()?.takeIf { it > 0 } - val data = runCatching { - Base64.decode(value.get("data")?.asText().orEmpty(), Base64.DEFAULT) - }.getOrNull() ?: return null + // Some messages use "timestamp" as timestamp field + @Serializable(InstantAsMillisSerializer::class) + @SerialName("timestamp") + private val t2: Instant? = null, - return RetrieveMessageResponse.Message(hash, timestamp, data) + @SerialName("data") + val dataB64: String? = null, + ) { + val data: ByteArray by lazy(LazyThreadSafetyMode.NONE) { + Base64.decode(dataB64, Base64.DEFAULT) + } + + val timestamp: Instant get() = requireNotNull(t1 ?: t2) { + "Message timestamp is missing" + } } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt b/app/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt index d01e013bd8..37545d464b 100644 --- a/app/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt +++ b/app/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt @@ -29,6 +29,7 @@ import network.loki.messenger.libsession_util.util.GroupInfo import network.loki.messenger.libsession_util.util.UserPic import org.session.libsession.snode.SwarmAuth import org.session.libsignal.utilities.AccountId +import java.time.Instant interface ConfigFactoryProtocol { val configUpdateNotifications: Flow @@ -103,7 +104,7 @@ class ConfigMessage( data class ConfigPushResult( val hashes: List, - val timestamp: Long + val timestamp: Instant ) enum class UserConfigType(val namespace: Int) { diff --git a/app/src/main/java/org/session/libsession/utilities/Contact.java b/app/src/main/java/org/session/libsession/utilities/Contact.java deleted file mode 100644 index 4bd0c1e01a..0000000000 --- a/app/src/main/java/org/session/libsession/utilities/Contact.java +++ /dev/null @@ -1,666 +0,0 @@ -package org.session.libsession.utilities; - -import android.net.Uri; -import android.os.Parcel; -import android.os.Parcelable; -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonProperty; - -import org.session.libsession.messaging.sending_receiving.attachments.Attachment; -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState; -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId; -import org.session.libsession.messaging.sending_receiving.attachments.UriAttachment; -import org.session.libsignal.utilities.JsonUtil; - -import java.io.IOException; -import java.util.Collections; -import java.util.List; - -public class Contact implements Parcelable { - - @JsonProperty - private final Name name; - - @JsonProperty - private final String organization; - - @JsonProperty - private final List phoneNumbers; - - @JsonProperty - private final List emails; - - @JsonProperty - private final List postalAddresses; - - @JsonProperty - private final Avatar avatar; - - public Contact(@JsonProperty("name") @NonNull Name name, - @JsonProperty("organization") @Nullable String organization, - @JsonProperty("phoneNumbers") @NonNull List phoneNumbers, - @JsonProperty("emails") @NonNull List emails, - @JsonProperty("postalAddresses") @NonNull List postalAddresses, - @JsonProperty("avatar") @Nullable Avatar avatar) - { - this.name = name; - this.organization = organization; - this.phoneNumbers = Collections.unmodifiableList(phoneNumbers); - this.emails = Collections.unmodifiableList(emails); - this.postalAddresses = Collections.unmodifiableList(postalAddresses); - this.avatar = avatar; - } - - public Contact(@NonNull Contact contact, @Nullable Avatar avatar) { - this(contact.getName(), - contact.getOrganization(), - contact.getPhoneNumbers(), - contact.getEmails(), - contact.getPostalAddresses(), - avatar); - } - - private Contact(Parcel in) { - this(in.readParcelable(Name.class.getClassLoader()), - in.readString(), - in.createTypedArrayList(Phone.CREATOR), - in.createTypedArrayList(Email.CREATOR), - in.createTypedArrayList(PostalAddress.CREATOR), - in.readParcelable(Avatar.class.getClassLoader())); - } - - public @NonNull Name getName() { - return name; - } - - public @Nullable String getOrganization() { - return organization; - } - - public @NonNull List getPhoneNumbers() { - return phoneNumbers; - } - - public @NonNull List getEmails() { - return emails; - } - - public @NonNull List getPostalAddresses() { - return postalAddresses; - } - - public @Nullable Avatar getAvatar() { - return avatar; - } - - @JsonIgnore - public @Nullable Attachment getAvatarAttachment() { - return avatar != null ? avatar.getAttachment() : null; - } - - public String serialize() throws IOException { - return JsonUtil.toJsonThrows(this); - } - - public static Contact deserialize(@NonNull String serialized) throws IOException { - return JsonUtil.fromJson(serialized, Contact.class); - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeParcelable(name, flags); - dest.writeString(organization); - dest.writeTypedList(phoneNumbers); - dest.writeTypedList(emails); - dest.writeTypedList(postalAddresses); - dest.writeParcelable(avatar, flags); - } - - public static final Creator CREATOR = new Creator() { - @Override - public Contact createFromParcel(Parcel in) { - return new Contact(in); - } - - @Override - public Contact[] newArray(int size) { - return new Contact[size]; - } - }; - - public static class Name implements Parcelable { - - @JsonProperty - private final String displayName; - - @JsonProperty - private final String givenName; - - @JsonProperty - private final String familyName; - - @JsonProperty - private final String prefix; - - @JsonProperty - private final String suffix; - - @JsonProperty - private final String middleName; - - public Name(@JsonProperty("displayName") @Nullable String displayName, - @JsonProperty("givenName") @Nullable String givenName, - @JsonProperty("familyName") @Nullable String familyName, - @JsonProperty("prefix") @Nullable String prefix, - @JsonProperty("suffix") @Nullable String suffix, - @JsonProperty("middleName") @Nullable String middleName) - { - this.displayName = displayName; - this.givenName = givenName; - this.familyName = familyName; - this.prefix = prefix; - this.suffix = suffix; - this.middleName = middleName; - } - - private Name(Parcel in) { - this(in.readString(), in.readString(), in.readString(), in.readString(), in.readString(), in.readString()); - } - - public @Nullable String getDisplayName() { - return displayName; - } - - public @Nullable String getGivenName() { - return givenName; - } - - public @Nullable String getFamilyName() { - return familyName; - } - - public @Nullable String getPrefix() { - return prefix; - } - - public @Nullable String getSuffix() { - return suffix; - } - - public @Nullable String getMiddleName() { - return middleName; - } - - public boolean isEmpty() { - return TextUtils.isEmpty(displayName) && - TextUtils.isEmpty(givenName) && - TextUtils.isEmpty(familyName) && - TextUtils.isEmpty(prefix) && - TextUtils.isEmpty(suffix) && - TextUtils.isEmpty(middleName); - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeString(displayName); - dest.writeString(givenName); - dest.writeString(familyName); - dest.writeString(prefix); - dest.writeString(suffix); - dest.writeString(middleName); - } - - public static final Creator CREATOR = new Creator() { - @Override - public Name createFromParcel(Parcel in) { - return new Name(in); - } - - @Override - public Name[] newArray(int size) { - return new Name[size]; - } - }; - } - - public static class Phone implements Selectable, Parcelable { - - @JsonProperty - private final String number; - - @JsonProperty - private final Type type; - - @JsonProperty - private final String label; - - @JsonIgnore - private boolean selected; - - public Phone(@JsonProperty("number") @NonNull String number, - @JsonProperty("type") @NonNull Type type, - @JsonProperty("label") @Nullable String label) - { - this.number = number; - this.type = type; - this.label = label; - this.selected = true; - } - - private Phone(Parcel in) { - this(in.readString(), Type.valueOf(in.readString()), in.readString()); - } - - public @NonNull String getNumber() { - return number; - } - - public @NonNull Type getType() { - return type; - } - - public @Nullable String getLabel() { - return label; - } - - @Override - public void setSelected(boolean selected) { - this.selected = selected; - } - - @Override - public boolean isSelected() { - return selected; - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeString(number); - dest.writeString(type.name()); - dest.writeString(label); - } - - public static final Creator CREATOR = new Creator() { - @Override - public Phone createFromParcel(Parcel in) { - return new Phone(in); - } - - @Override - public Phone[] newArray(int size) { - return new Phone[size]; - } - }; - - public enum Type { - HOME, MOBILE, WORK, CUSTOM - } - } - - public static class Email implements Selectable, Parcelable { - - @JsonProperty - private final String email; - - @JsonProperty - private final Type type; - - @JsonProperty - private final String label; - - @JsonIgnore - private boolean selected; - - public Email(@JsonProperty("email") @NonNull String email, - @JsonProperty("type") @NonNull Type type, - @JsonProperty("label") @Nullable String label) - { - this.email = email; - this.type = type; - this.label = label; - this.selected = true; - } - - private Email(Parcel in) { - this(in.readString(), Type.valueOf(in.readString()), in.readString()); - } - - public @NonNull String getEmail() { - return email; - } - - public @NonNull Type getType() { - return type; - } - - public @NonNull String getLabel() { - return label; - } - - @Override - public void setSelected(boolean selected) { - this.selected = selected; - } - - @Override - public boolean isSelected() { - return selected; - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeString(email); - dest.writeString(type.name()); - dest.writeString(label); - } - - public static final Creator CREATOR = new Creator() { - @Override - public Email createFromParcel(Parcel in) { - return new Email(in); - } - - @Override - public Email[] newArray(int size) { - return new Email[size]; - } - }; - - public enum Type { - HOME, MOBILE, WORK, CUSTOM - } - } - - public static class PostalAddress implements Selectable, Parcelable { - - @JsonProperty - private final Type type; - - @JsonProperty - private final String label; - - @JsonProperty - private final String street; - - @JsonProperty - private final String poBox; - - @JsonProperty - private final String neighborhood; - - @JsonProperty - private final String city; - - @JsonProperty - private final String region; - - @JsonProperty - private final String postalCode; - - @JsonProperty - private final String country; - - @JsonIgnore - private boolean selected; - - public PostalAddress(@JsonProperty("type") @NonNull Type type, - @JsonProperty("label") @Nullable String label, - @JsonProperty("street") @Nullable String street, - @JsonProperty("poBox") @Nullable String poBox, - @JsonProperty("neighborhood") @Nullable String neighborhood, - @JsonProperty("city") @Nullable String city, - @JsonProperty("region") @Nullable String region, - @JsonProperty("postalCode") @Nullable String postalCode, - @JsonProperty("country") @Nullable String country) - { - this.type = type; - this.label = label; - this.street = street; - this.poBox = poBox; - this.neighborhood = neighborhood; - this.city = city; - this.region = region; - this.postalCode = postalCode; - this.country = country; - this.selected = true; - } - - private PostalAddress(Parcel in) { - this(Type.valueOf(in.readString()), - in.readString(), - in.readString(), - in.readString(), - in.readString(), - in.readString(), - in.readString(), - in.readString(), - in.readString()); - } - - public @NonNull Type getType() { - return type; - } - - public @Nullable String getLabel() { - return label; - } - - public @Nullable String getStreet() { - return street; - } - - public @Nullable String getPoBox() { - return poBox; - } - - public @Nullable String getNeighborhood() { - return neighborhood; - } - - public @Nullable String getCity() { - return city; - } - - public @Nullable String getRegion() { - return region; - } - - public @Nullable String getPostalCode() { - return postalCode; - } - - public @Nullable String getCountry() { - return country; - } - - @Override - public void setSelected(boolean selected) { - this.selected = selected; - } - - @Override - public boolean isSelected() { - return selected; - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeString(type.name()); - dest.writeString(label); - dest.writeString(street); - dest.writeString(poBox); - dest.writeString(neighborhood); - dest.writeString(city); - dest.writeString(region); - dest.writeString(postalCode); - dest.writeString(country); - } - - public static final Creator CREATOR = new Creator() { - @Override - public PostalAddress createFromParcel(Parcel in) { - return new PostalAddress(in); - } - - @Override - public PostalAddress[] newArray(int size) { - return new PostalAddress[size]; - } - }; - - @Override - public @NonNull String toString() { - StringBuilder builder = new StringBuilder(); - - if (!TextUtils.isEmpty(street)) { - builder.append(street).append('\n'); - } - - if (!TextUtils.isEmpty(poBox)) { - builder.append(poBox).append('\n'); - } - - if (!TextUtils.isEmpty(neighborhood)) { - builder.append(neighborhood).append('\n'); - } - - if (!TextUtils.isEmpty(city) && !TextUtils.isEmpty(region)) { - builder.append(city).append(", ").append(region); - } else if (!TextUtils.isEmpty(city)) { - builder.append(city).append(' '); - } else if (!TextUtils.isEmpty(region)) { - builder.append(region).append(' '); - } - - if (!TextUtils.isEmpty(postalCode)) { - builder.append(postalCode); - } - - if (!TextUtils.isEmpty(country)) { - builder.append('\n').append(country); - } - - return builder.toString().trim(); - } - - public enum Type { - HOME, WORK, CUSTOM - } - } - - public static class Avatar implements Selectable, Parcelable { - - @JsonProperty - private final AttachmentId attachmentId; - - @JsonProperty - private final boolean isProfile; - - @JsonIgnore - private final Attachment attachment; - - @JsonIgnore - private boolean selected; - - public Avatar(@Nullable AttachmentId attachmentId, @Nullable Attachment attachment, boolean isProfile) { - this.attachmentId = attachmentId; - this.attachment = attachment; - this.isProfile = isProfile; - this.selected = true; - } - - Avatar(@Nullable Uri attachmentUri, boolean isProfile) { - this(null, attachmentFromUri(attachmentUri), isProfile); - } - - @JsonCreator - private Avatar(@JsonProperty("attachmentId") @Nullable AttachmentId attachmentId, @JsonProperty("isProfile") boolean isProfile) { - this(attachmentId, null, isProfile); - } - - private Avatar(Parcel in) { - this((Uri) in.readParcelable(Uri.class.getClassLoader()), in.readByte() != 0); - } - - public @Nullable AttachmentId getAttachmentId() { - return attachmentId; - } - - public @Nullable Attachment getAttachment() { - return attachment; - } - - public boolean isProfile() { - return isProfile; - } - - @Override - public void setSelected(boolean selected) { - this.selected = selected; - } - - @Override - public boolean isSelected() { - return selected; - } - - @Override - public int describeContents() { - return 0; - } - - private static Attachment attachmentFromUri(@Nullable Uri uri) { - if (uri == null) return null; - return new UriAttachment(uri, MediaTypes.IMAGE_JPEG, AttachmentState.DONE.getValue(), 0, null, false, false, null); - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeParcelable(attachment != null ? attachment.getDataUri() : null, flags); - dest.writeByte((byte) (isProfile ? 1 : 0)); - } - - public static final Creator CREATOR = new Creator() { - @Override - public Avatar createFromParcel(Parcel in) { - return new Avatar(in); - } - - @Override - public Avatar[] newArray(int size) { - return new Avatar[size]; - } - }; - } -} diff --git a/app/src/main/java/org/session/libsession/utilities/DistributionTypes.kt b/app/src/main/java/org/session/libsession/utilities/DistributionTypes.kt deleted file mode 100644 index c6e8fcfd9d..0000000000 --- a/app/src/main/java/org/session/libsession/utilities/DistributionTypes.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.session.libsession.utilities - -object DistributionTypes { - const val DEFAULT = 2 - const val BROADCAST = 1 - const val CONVERSATION = 2 - const val ARCHIVE = 3 - const val INBOX_ZERO = 4 -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/utilities/IdentityKeyMismatch.java b/app/src/main/java/org/session/libsession/utilities/IdentityKeyMismatch.java deleted file mode 100644 index e317224e59..0000000000 --- a/app/src/main/java/org/session/libsession/utilities/IdentityKeyMismatch.java +++ /dev/null @@ -1,88 +0,0 @@ -package org.session.libsession.utilities; - -import org.session.libsignal.utilities.Log; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.SerializerProvider; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; - -import org.session.libsession.utilities.Address; -import org.session.libsignal.utilities.Base64; -import org.session.libsignal.crypto.IdentityKey; -import org.session.libsignal.exceptions.InvalidKeyException; - -import java.io.IOException; - -public class IdentityKeyMismatch { - - private static final String TAG = IdentityKeyMismatch.class.getSimpleName(); - - @JsonProperty(value = "a") - private String address; - - @JsonProperty(value = "k") - @JsonSerialize(using = IdentityKeySerializer.class) - @JsonDeserialize(using = IdentityKeyDeserializer.class) - private IdentityKey identityKey; - - public IdentityKeyMismatch() {} - - public IdentityKeyMismatch(Address address, IdentityKey identityKey) { - this.address = address.toString(); - this.identityKey = identityKey; - } - - @JsonIgnore - public Address getAddress() { - return Address.fromSerialized(address); - } - - public IdentityKey getIdentityKey() { - return identityKey; - } - - @Override - public boolean equals(Object other) { - if (other == null || !(other instanceof IdentityKeyMismatch)) { - return false; - } - - IdentityKeyMismatch that = (IdentityKeyMismatch)other; - return that.address.equals(this.address) && that.identityKey.equals(this.identityKey); - } - - @Override - public int hashCode() { - return address.hashCode() ^ identityKey.hashCode(); - } - - private static class IdentityKeySerializer extends JsonSerializer { - @Override - public void serialize(IdentityKey value, JsonGenerator jsonGenerator, SerializerProvider serializers) - throws IOException - { - jsonGenerator.writeString(Base64.encodeBytes(value.serialize())); - } - } - - private static class IdentityKeyDeserializer extends JsonDeserializer { - @Override - public IdentityKey deserialize(JsonParser jsonParser, DeserializationContext ctxt) - throws IOException - { - try { - return new IdentityKey(Base64.decode(jsonParser.getValueAsString()), 0); - } catch (InvalidKeyException e) { - Log.w(TAG, e); - throw new IOException(e); - } - } - } -} diff --git a/app/src/main/java/org/session/libsession/utilities/IdentityKeyMismatchList.java b/app/src/main/java/org/session/libsession/utilities/IdentityKeyMismatchList.java deleted file mode 100644 index e1ea2d26e4..0000000000 --- a/app/src/main/java/org/session/libsession/utilities/IdentityKeyMismatchList.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.session.libsession.utilities; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.LinkedList; -import java.util.List; - -public class IdentityKeyMismatchList implements Document { - - @JsonProperty(value = "m") - private List mismatches; - - public IdentityKeyMismatchList() { - this.mismatches = new LinkedList<>(); - } - - public IdentityKeyMismatchList(List mismatches) { - this.mismatches = mismatches; - } - - @Override - public int size() { - if (mismatches == null) return 0; - else return mismatches.size(); - } - - @Override - @JsonIgnore - public List getList() { - return mismatches; - } -} diff --git a/app/src/main/java/org/session/libsession/utilities/NetworkFailure.java b/app/src/main/java/org/session/libsession/utilities/NetworkFailure.java deleted file mode 100644 index f999e92d36..0000000000 --- a/app/src/main/java/org/session/libsession/utilities/NetworkFailure.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.session.libsession.utilities; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonProperty; - -import org.session.libsession.utilities.Address; - -public class NetworkFailure { - - @JsonProperty(value = "a") - private String address; - - public NetworkFailure(Address address) { - this.address = address.toString(); - } - - public NetworkFailure() {} - - @JsonIgnore - public Address getAddress() { - return Address.fromSerialized(address); - } - - @Override - public boolean equals(Object other) { - if (other == null || !(other instanceof NetworkFailure)) return false; - - NetworkFailure that = (NetworkFailure)other; - return this.address.equals(that.address); - } - - @Override - public int hashCode() { - return address.hashCode(); - } -} diff --git a/app/src/main/java/org/session/libsession/utilities/NetworkFailureList.java b/app/src/main/java/org/session/libsession/utilities/NetworkFailureList.java deleted file mode 100644 index ef5faa6460..0000000000 --- a/app/src/main/java/org/session/libsession/utilities/NetworkFailureList.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.session.libsession.utilities; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.LinkedList; -import java.util.List; - -public class NetworkFailureList implements Document { - - @JsonProperty(value = "l") - private List failures; - - public NetworkFailureList() { - this.failures = new LinkedList<>(); - } - - public NetworkFailureList(List failures) { - this.failures = failures; - } - - @Override - public int size() { - if (failures == null) return 0; - else return failures.size(); - } - - @Override - @JsonIgnore - public List getList() { - return failures; - } -} diff --git a/app/src/main/java/org/session/libsession/utilities/NonTranslatableStringConstants.kt b/app/src/main/java/org/session/libsession/utilities/NonTranslatableStringConstants.kt index 2266df0467..96dd03f4b5 100644 --- a/app/src/main/java/org/session/libsession/utilities/NonTranslatableStringConstants.kt +++ b/app/src/main/java/org/session/libsession/utilities/NonTranslatableStringConstants.kt @@ -15,5 +15,7 @@ object NonTranslatableStringConstants { const val APP_PRO = "Session Pro" const val SESSION_FOUNDATION = "Session Foundation" const val PRO = "Pro" + const val ENTITY_RANGEPROOF = "Rangeproof PTY LTD" + const val ENTITY_STF = "The Session Technology Foundation" } diff --git a/app/src/main/java/org/session/libsession/utilities/Selectable.java b/app/src/main/java/org/session/libsession/utilities/Selectable.java deleted file mode 100644 index 8bb77f6242..0000000000 --- a/app/src/main/java/org/session/libsession/utilities/Selectable.java +++ /dev/null @@ -1,6 +0,0 @@ -package org.session.libsession.utilities; - -public interface Selectable { - void setSelected(boolean selected); - boolean isSelected(); -} diff --git a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt index 09858a3ecd..4008f3f1bb 100644 --- a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt +++ b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt @@ -47,15 +47,22 @@ object StringSubstitutionConstants { const val VERSION_KEY: StringSubKey = "version" const val LIMIT_KEY: StringSubKey = "limit" const val STORE_VARIANT_KEY: StringSubKey = "storevariant" + const val BUILD_VARIANT_KEY: StringSubKey = "build_variant" const val APP_PRO_KEY: StringSubKey = "app_pro" const val PRO_KEY: StringSubKey = "pro" - const val CURRENT_PLAN_KEY: StringSubKey = "current_plan" - const val SELECTED_PLAN_KEY: StringSubKey = "selected_plan" + const val CURRENT_PLAN_LENGTH_KEY: StringSubKey = "current_plan_length" + const val SELECTED_PLAN_LENGTH_KEY: StringSubKey = "selected_plan_length" + const val SELECTED_PLAN_LENGTH_SINGULAR_KEY: StringSubKey = "selected_plan_length_singular" + const val PLATFORM_KEY: StringSubKey = "platform" const val PLATFORM_STORE_KEY: StringSubKey = "platform_store" + const val PLATFORM_STORE2_KEY: StringSubKey = "platform_store_other" const val PLATFORM_ACCOUNT_KEY: StringSubKey = "platform_account" const val MONTHLY_PRICE_KEY: StringSubKey = "monthly_price" const val PRICE_KEY: StringSubKey = "price" const val PERCENT_KEY: StringSubKey = "percent" const val DEVICE_TYPE_KEY: StringSubKey = "device_type" const val SESSION_FOUNDATION_KEY: StringSubKey = "session_foundation" + const val ACTION_TYPE_KEY: StringSubKey = "action_type" + const val ACTIVATION_TYPE_KEY: StringSubKey = "activation_type" + const val ENTITY_KEY: StringSubKey = "entity" } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt index c6b348e2c6..caa5489453 100644 --- a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -20,23 +20,37 @@ import kotlinx.coroutines.flow.update import kotlinx.serialization.json.Json import network.loki.messenger.BuildConfig import network.loki.messenger.R +import network.loki.messenger.libsession_util.protocol.ProFeature +import network.loki.messenger.libsession_util.protocol.ProMessageFeature +import network.loki.messenger.libsession_util.protocol.ProProfileFeature +import network.loki.messenger.libsession_util.util.toBitSet import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.file_server.FileServer import org.session.libsession.utilities.TextSecurePreferences.Companion.AUTOPLAY_AUDIO_MESSAGES import org.session.libsession.utilities.TextSecurePreferences.Companion.CALL_NOTIFICATIONS_ENABLED 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.DEBUG_HAS_COPIED_DONATION_URL +import org.session.libsession.utilities.TextSecurePreferences.Companion.DEBUG_HAS_DONATED +import org.session.libsession.utilities.TextSecurePreferences.Companion.DEBUG_SEEN_DONATION_CTA_AMOUNT +import org.session.libsession.utilities.TextSecurePreferences.Companion.DEBUG_SHOW_DONATION_CTA_FROM_POSITIVE_REVIEW import org.session.libsession.utilities.TextSecurePreferences.Companion.ENVIRONMENT import org.session.libsession.utilities.TextSecurePreferences.Companion.FOLLOW_SYSTEM_SETTINGS import org.session.libsession.utilities.TextSecurePreferences.Companion.FORCED_SHORT_TTL +import org.session.libsession.utilities.TextSecurePreferences.Companion.HAS_COPIED_DONATION_URL +import org.session.libsession.utilities.TextSecurePreferences.Companion.HAS_DONATED import org.session.libsession.utilities.TextSecurePreferences.Companion.HAS_HIDDEN_MESSAGE_REQUESTS +import org.session.libsession.utilities.TextSecurePreferences.Companion.HAS_SEEN_PRO_EXPIRED +import org.session.libsession.utilities.TextSecurePreferences.Companion.HAS_SEEN_PRO_EXPIRING import org.session.libsession.utilities.TextSecurePreferences.Companion.HAVE_SHOWN_A_NOTIFICATION_ABOUT_TOKEN_PAGE import org.session.libsession.utilities.TextSecurePreferences.Companion.HIDE_PASSWORD +import org.session.libsession.utilities.TextSecurePreferences.Companion.LAST_SEEN_DONATION_CTA import org.session.libsession.utilities.TextSecurePreferences.Companion.LAST_VACUUM_TIME import org.session.libsession.utilities.TextSecurePreferences.Companion.LAST_VERSION_CHECK import org.session.libsession.utilities.TextSecurePreferences.Companion.LEGACY_PREF_KEY_SELECTED_UI_MODE import org.session.libsession.utilities.TextSecurePreferences.Companion.OCEAN_DARK import org.session.libsession.utilities.TextSecurePreferences.Companion.OCEAN_LIGHT +import org.session.libsession.utilities.TextSecurePreferences.Companion.SEEN_DONATION_CTA_AMOUNT import org.session.libsession.utilities.TextSecurePreferences.Companion.SELECTED_ACCENT_COLOR import org.session.libsession.utilities.TextSecurePreferences.Companion.SELECTED_STYLE import org.session.libsession.utilities.TextSecurePreferences.Companion.SET_FORCE_CURRENT_USER_PRO @@ -45,10 +59,12 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.SET_FORC import org.session.libsession.utilities.TextSecurePreferences.Companion.SET_FORCE_POST_PRO import org.session.libsession.utilities.TextSecurePreferences.Companion.SHOWN_CALL_NOTIFICATION import org.session.libsession.utilities.TextSecurePreferences.Companion.SHOWN_CALL_WARNING +import org.session.libsession.utilities.TextSecurePreferences.Companion.SHOW_DONATION_CTA_FROM_POSITIVE_REVIEW import org.session.libsession.utilities.TextSecurePreferences.Companion._events import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel -import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.pro.toProMessageFeatures +import org.thoughtcrime.securesms.pro.toProProfileFeatures import java.io.IOException import java.time.ZonedDateTime import java.util.Arrays @@ -104,8 +120,6 @@ interface TextSecurePreferences { fun getPreferredCameraDirection(): CameraSelector fun getNotificationPrivacy(): NotificationPrivacyPreference fun getRepeatAlertsCount(): Int - fun getLocalRegistrationId(): Int - fun setLocalRegistrationId(registrationId: Int) fun isInThreadNotifications(): Boolean fun isUniversalUnidentifiedAccess(): Boolean fun getUpdateApkRefreshTime(): Long @@ -114,12 +128,8 @@ interface TextSecurePreferences { fun getUpdateApkDownloadId(): Long fun setUpdateApkDigest(value: String?) fun getUpdateApkDigest(): String? - fun getLocalNumber(): String? - fun watchLocalNumber(): StateFlow fun getHasLegacyConfig(): Boolean fun setHasLegacyConfig(newValue: Boolean) - fun setLocalNumber(localNumber: String) - fun removeLocalNumber() fun isEnterSendsEnabled(): Boolean fun isPasswordDisabled(): Boolean fun setPasswordDisabled(disabled: Boolean) @@ -158,8 +168,6 @@ interface TextSecurePreferences { fun setStringSetPreference(key: String, value: Set) fun getHasViewedSeed(): Boolean fun setHasViewedSeed(hasViewedSeed: Boolean) - fun setRestorationTime(time: Long) - fun getRestorationTime(): Long fun getLastSnodePoolRefreshDate(): Long fun setLastSnodePoolRefreshDate(date: Date) fun getLastOpenTimeDate(): Long @@ -176,6 +184,10 @@ interface TextSecurePreferences { fun setForceIncomingMessagesAsPro(isPro: Boolean) fun forcePostPro(): Boolean fun setForcePostPro(postPro: Boolean) + fun hasSeenProExpiring(): Boolean + fun setHasSeenProExpiring() + fun hasSeenProExpired(): Boolean + fun setHasSeenProExpired() fun watchPostProStatus(): StateFlow fun setShownCallWarning(): Boolean fun setShownCallNotification(): Boolean @@ -207,15 +219,41 @@ interface TextSecurePreferences { fun forcedShortTTL(): Boolean fun setForcedShortTTL(value: Boolean) - fun getDebugMessageFeatures(): Set - fun setDebugMessageFeatures(features: Set) + fun getDebugMessageFeatures(): Set + fun setDebugMessageFeatures(features: Set) fun getDebugSubscriptionType(): DebugMenuViewModel.DebugSubscriptionStatus? fun setDebugSubscriptionType(status: DebugMenuViewModel.DebugSubscriptionStatus?) + fun getDebugProPlanStatus(): DebugMenuViewModel.DebugProPlanStatus? + fun setDebugProPlanStatus(status: DebugMenuViewModel.DebugProPlanStatus?) + fun getDebugForceNoBilling(): Boolean + fun setDebugForceNoBilling(hasBilling: Boolean) + fun getDebugIsWithinQuickRefund(): Boolean + fun setDebugIsWithinQuickRefund(isWithin: Boolean) fun setSubscriptionProvider(provider: String) fun getSubscriptionProvider(): String? + fun hasDonated(): Boolean + fun setHasDonated(hasDonated: Boolean) + fun hasCopiedDonationURL(): Boolean + fun setHasCopiedDonationURL(hasCopied: Boolean) + fun seenDonationCTAAmount(): Int + fun setSeenDonationCTAAmount(amount: Int) + fun lastSeenDonationCTA(): Long + fun setLastSeenDonationCTA(timestamp: Long) + fun showDonationCTAFromPositiveReview(): Boolean + fun setShowDonationCTAFromPositiveReview(show: Boolean) + + fun hasDonatedDebug(): String? + fun setHasDonatedDebug(hasDonated: String?) + fun hasCopiedDonationURLDebug(): String? + fun setHasCopiedDonationURLDebug(hasCopied: String?) + fun seenDonationCTAAmountDebug(): String? + fun setSeenDonationCTAAmountDebug(amount: String?) + fun showDonationCTAFromPositiveReviewDebug(): String? + fun setShowDonationCTAFromPositiveReviewDebug(show: String?) + var deprecationStateOverride: String? var deprecatedTimeOverride: ZonedDateTime? var deprecatingStartTimeOverride: ZonedDateTime? @@ -262,7 +300,6 @@ interface TextSecurePreferences { const val PASSPHRASE_TIMEOUT_PREF = "pref_timeout_passphrase" const val ENTER_SENDS_PREF = "pref_enter_sends" const val THREAD_TRIM_ENABLED = "pref_trim_threads" - internal const val LOCAL_NUMBER_PREF = "pref_local_number" const val REGISTERED_GCM_PREF = "pref_gcm_registered" const val UPDATE_APK_REFRESH_TIME_PREF = "pref_update_apk_refresh_time" const val UPDATE_APK_DOWNLOAD_ID = "pref_update_apk_download_id" @@ -270,6 +307,7 @@ interface TextSecurePreferences { const val IN_THREAD_NOTIFICATION_PREF = "pref_key_inthread_notifications" const val IN_APP_NOTIFICATION_SOUNDS = "pref_sound_when_app_open" const val MESSAGE_BODY_TEXT_SIZE_PREF = "pref_message_body_text_size" + @Deprecated("No longer used, kept for migration purposes") const val LOCAL_REGISTRATION_ID_PREF = "pref_local_registration_id" const val REPEAT_ALERTS_PREF = "pref_repeat_alerts" const val NOTIFICATION_PRIVACY_PREF = "pref_notification_privacy" @@ -311,6 +349,8 @@ interface TextSecurePreferences { const val SET_FORCE_OTHER_USERS_PRO = "pref_force_other_users_pro" const val SET_FORCE_INCOMING_MESSAGE_PRO = "pref_force_incoming_message_pro" const val SET_FORCE_POST_PRO = "pref_force_post_pro" + const val HAS_SEEN_PRO_EXPIRING = "has_seen_pro_expiring" + const val HAS_SEEN_PRO_EXPIRED = "has_seen_pro_expired" const val CALL_NOTIFICATIONS_ENABLED = "pref_call_notifications_enabled" const val SHOWN_CALL_WARNING = "pref_shown_call_warning" // call warning is user-facing warning of enabling calls const val SHOWN_CALL_NOTIFICATION = "pref_shown_call_notification" // call notification is a prompt to check privacy settings @@ -372,12 +412,28 @@ interface TextSecurePreferences { const val IN_APP_REVIEW_STATE = "in_app_review_state" - const val DEBUG_MESSAGE_FEATURES = "debug_message_features" + const val DEBUG_PRO_MESSAGE_FEATURES = "debug_pro_message_features" + const val DEBUG_PRO_PROFILE_FEATURES = "debug_pro_profile_features" const val DEBUG_SUBSCRIPTION_STATUS = "debug_subscription_status" + const val DEBUG_PRO_PLAN_STATUS = "debug_pro_plan_status" + const val DEBUG_FORCE_NO_BILLING = "debug_pro_has_billing" + const val DEBUG_WITHIN_QUICK_REFUND = "debug_within_quick_refund" const val SUBSCRIPTION_PROVIDER = "session_subscription_provider" const val DEBUG_AVATAR_REUPLOAD = "debug_avatar_reupload" + // Donation + const val HAS_DONATED = "has_donated" + const val HAS_COPIED_DONATION_URL = "has_copied_donation_url" + const val SEEN_DONATION_CTA_AMOUNT = "seen_donation_cta_amount" + const val LAST_SEEN_DONATION_CTA = "last_seen_donation_cta" + const val SHOW_DONATION_CTA_FROM_POSITIVE_REVIEW = "show_donation_cta_from_positive_review" + + const val DEBUG_HAS_DONATED = "debug_has_donated" + const val DEBUG_HAS_COPIED_DONATION_URL = "debug_has_copied_donation_url" + const val DEBUG_SEEN_DONATION_CTA_AMOUNT = "debug_seen_donation_cta_amount" + const val DEBUG_SHOW_DONATION_CTA_FROM_POSITIVE_REVIEW = "debug_show_donation_cta_from_positive_review" + @JvmStatic fun getConfigurationMessageSynced(context: Context): Boolean { return getBooleanPreference(context, CONFIGURATION_SYNCED, false) @@ -629,15 +685,6 @@ interface TextSecurePreferences { return getStringPreference(context, UPDATE_APK_DIGEST, null) } - @Deprecated( - "Use the dependency-injected TextSecurePreference instance instead", - ReplaceWith("TextSecurePreferences.getLocalNumber()") - ) - @JvmStatic - fun getLocalNumber(context: Context): String? { - return preferenceInstance.getLocalNumber() - } - @JvmStatic fun getHasLegacyConfig(context: Context): Boolean { return getBooleanPreference(context, HAS_RECEIVED_LEGACY_CONFIG, false) @@ -970,7 +1017,6 @@ class AppTextSecurePreferences @Inject constructor( @param:ApplicationContext private val context: Context, private val json: Json, ): TextSecurePreferences { - private val localNumberState = MutableStateFlow(getStringPreference(TextSecurePreferences.LOCAL_NUMBER_PREF, null)) private val postProLaunchState = MutableStateFlow(getBooleanPreference(SET_FORCE_POST_PRO, false)) private val hiddenPasswordState = MutableStateFlow(getBooleanPreference(HIDE_PASSWORD, false)) @@ -1190,14 +1236,6 @@ class AppTextSecurePreferences @Inject constructor( } } - override fun getLocalRegistrationId(): Int { - return getIntegerPreference(TextSecurePreferences.LOCAL_REGISTRATION_ID_PREF, 0) - } - - override fun setLocalRegistrationId(registrationId: Int) { - setIntegerPreference(TextSecurePreferences.LOCAL_REGISTRATION_ID_PREF, registrationId) - } - override fun isInThreadNotifications(): Boolean { return getBooleanPreference(TextSecurePreferences.IN_THREAD_NOTIFICATION_PREF, true) } @@ -1230,14 +1268,6 @@ class AppTextSecurePreferences @Inject constructor( return getStringPreference(TextSecurePreferences.UPDATE_APK_DIGEST, null) } - override fun getLocalNumber(): String? { - return localNumberState.value - } - - override fun watchLocalNumber(): StateFlow { - return localNumberState - } - override fun getHasLegacyConfig(): Boolean { return getBooleanPreference(TextSecurePreferences.HAS_RECEIVED_LEGACY_CONFIG, false) } @@ -1247,17 +1277,6 @@ class AppTextSecurePreferences @Inject constructor( _events.tryEmit(TextSecurePreferences.HAS_RECEIVED_LEGACY_CONFIG) } - override fun setLocalNumber(localNumber: String) { - val normalised = localNumber.lowercase() - setStringPreference(TextSecurePreferences.LOCAL_NUMBER_PREF, normalised) - localNumberState.value = normalised - } - - override fun removeLocalNumber() { - localNumberState.value = null - removePreference(TextSecurePreferences.LOCAL_NUMBER_PREF) - } - override fun isEnterSendsEnabled(): Boolean { return getBooleanPreference(TextSecurePreferences.ENTER_SENDS_PREF, false) } @@ -1429,14 +1448,6 @@ class AppTextSecurePreferences @Inject constructor( setBooleanPreference("has_viewed_seed", hasViewedSeed) } - override fun setRestorationTime(time: Long) { - setLongPreference("restoration_time", time) - } - - override fun getRestorationTime(): Long { - return getLongPreference("restoration_time", 0) - } - override fun getLastSnodePoolRefreshDate(): Long { return getLongPreference("last_snode_pool_refresh_date", 0) } @@ -1550,7 +1561,7 @@ class AppTextSecurePreferences @Inject constructor( } override fun forcePostPro(): Boolean { - return getBooleanPreference(SET_FORCE_POST_PRO, false) + return postProLaunchState.value } override fun setForcePostPro(postPro: Boolean) { @@ -1559,6 +1570,22 @@ class AppTextSecurePreferences @Inject constructor( _events.tryEmit(SET_FORCE_POST_PRO) } + override fun hasSeenProExpiring(): Boolean { + return getBooleanPreference(HAS_SEEN_PRO_EXPIRING, false) + } + + override fun setHasSeenProExpiring() { + setBooleanPreference(HAS_SEEN_PRO_EXPIRING, true) + } + + override fun hasSeenProExpired(): Boolean { + return getBooleanPreference(HAS_SEEN_PRO_EXPIRED, false) + } + + override fun setHasSeenProExpired() { + setBooleanPreference(HAS_SEEN_PRO_EXPIRED, true) + } + override fun watchPostProStatus(): StateFlow { return postProLaunchState } @@ -1662,7 +1689,6 @@ class AppTextSecurePreferences @Inject constructor( */ override fun clearAll() { pushEnabled.update { false } - localNumberState.update { null } postProLaunchState.update { false } hiddenPasswordState.update { false } @@ -1729,13 +1755,16 @@ class AppTextSecurePreferences @Inject constructor( setStringPreference(TextSecurePreferences.DEPRECATING_START_TIME_OVERRIDE, value.toString()) } } - override fun getDebugMessageFeatures(): Set { - return getStringSetPreference( TextSecurePreferences.DEBUG_MESSAGE_FEATURES, emptySet()) - ?.map { ProStatusManager.MessageProFeature.valueOf(it) }?.toSet() ?: emptySet() + override fun getDebugMessageFeatures(): Set { + return buildSet { + getLongPreference(TextSecurePreferences.DEBUG_PRO_MESSAGE_FEATURES, 0L).toProMessageFeatures(this) + getLongPreference(TextSecurePreferences.DEBUG_PRO_PROFILE_FEATURES, 0L).toProProfileFeatures(this) + } } - override fun setDebugMessageFeatures(features: Set) { - setStringSetPreference(TextSecurePreferences.DEBUG_MESSAGE_FEATURES, features.map { it.name }.toSet()) + override fun setDebugMessageFeatures(features: Set) { + setLongPreference(TextSecurePreferences.DEBUG_PRO_MESSAGE_FEATURES, features.filterIsInstance().toBitSet().rawValue) + setLongPreference(TextSecurePreferences.DEBUG_PRO_PROFILE_FEATURES, features.filterIsInstance().toBitSet().rawValue) } override fun getDebugSubscriptionType(): DebugMenuViewModel.DebugSubscriptionStatus? { @@ -1749,6 +1778,35 @@ class AppTextSecurePreferences @Inject constructor( _events.tryEmit(TextSecurePreferences.DEBUG_SUBSCRIPTION_STATUS) } + override fun getDebugProPlanStatus(): DebugMenuViewModel.DebugProPlanStatus? { + return getStringPreference(TextSecurePreferences.DEBUG_PRO_PLAN_STATUS, null)?.let { + DebugMenuViewModel.DebugProPlanStatus.valueOf(it) + } + } + + override fun setDebugProPlanStatus(status: DebugMenuViewModel.DebugProPlanStatus?) { + setStringPreference(TextSecurePreferences.DEBUG_PRO_PLAN_STATUS, status?.name) + _events.tryEmit(TextSecurePreferences.DEBUG_PRO_PLAN_STATUS) + } + + override fun getDebugForceNoBilling(): Boolean { + return getBooleanPreference(TextSecurePreferences.DEBUG_FORCE_NO_BILLING, false) + } + + override fun setDebugForceNoBilling(hasBilling: Boolean) { + setBooleanPreference(TextSecurePreferences.DEBUG_FORCE_NO_BILLING, hasBilling) + _events.tryEmit(TextSecurePreferences.DEBUG_FORCE_NO_BILLING) + } + + override fun getDebugIsWithinQuickRefund(): Boolean { + return getBooleanPreference(TextSecurePreferences.DEBUG_WITHIN_QUICK_REFUND, false) + } + + override fun setDebugIsWithinQuickRefund(isWithin: Boolean) { + setBooleanPreference(TextSecurePreferences.DEBUG_WITHIN_QUICK_REFUND, isWithin) + _events.tryEmit(TextSecurePreferences.DEBUG_FORCE_NO_BILLING) + } + override fun getSubscriptionProvider(): String? { return getStringPreference(TextSecurePreferences.SUBSCRIPTION_PROVIDER, null) } @@ -1780,4 +1838,67 @@ class AppTextSecurePreferences @Inject constructor( json.encodeToString(it) }) } + + override fun hasDonated(): Boolean { + return getBooleanPreference(HAS_DONATED, false) + } + override fun setHasDonated(hasDonated: Boolean) { + setBooleanPreference(HAS_DONATED, hasDonated) + } + + override fun hasCopiedDonationURL(): Boolean { + return getBooleanPreference(HAS_COPIED_DONATION_URL, false) + } + override fun setHasCopiedDonationURL(hasCopied: Boolean) { + setBooleanPreference(HAS_COPIED_DONATION_URL, hasCopied) + } + + override fun seenDonationCTAAmount(): Int { + return getIntegerPreference(SEEN_DONATION_CTA_AMOUNT, 0) + } + override fun setSeenDonationCTAAmount(amount: Int) { + setIntegerPreference(SEEN_DONATION_CTA_AMOUNT, amount) + } + + override fun lastSeenDonationCTA(): Long { + return getLongPreference(LAST_SEEN_DONATION_CTA, 0) + } + override fun setLastSeenDonationCTA(timestamp: Long) { + setLongPreference(LAST_SEEN_DONATION_CTA, timestamp) + } + + override fun showDonationCTAFromPositiveReview(): Boolean { + return getBooleanPreference(SHOW_DONATION_CTA_FROM_POSITIVE_REVIEW, false) + } + override fun setShowDonationCTAFromPositiveReview(show: Boolean) { + setBooleanPreference(SHOW_DONATION_CTA_FROM_POSITIVE_REVIEW, show) + } + + override fun hasDonatedDebug(): String? { + return getStringPreference(DEBUG_HAS_DONATED, null) + } + override fun setHasDonatedDebug(hasDonated: String?) { + setStringPreference(DEBUG_HAS_DONATED, hasDonated) + } + + override fun hasCopiedDonationURLDebug(): String? { + return getStringPreference(DEBUG_HAS_COPIED_DONATION_URL, null) + } + override fun setHasCopiedDonationURLDebug(hasCopied: String?) { + setStringPreference(DEBUG_HAS_COPIED_DONATION_URL, hasCopied) + } + + override fun seenDonationCTAAmountDebug(): String? { + return getStringPreference(DEBUG_SEEN_DONATION_CTA_AMOUNT, null) + } + override fun setSeenDonationCTAAmountDebug(amount: String?) { + setStringPreference(DEBUG_SEEN_DONATION_CTA_AMOUNT, amount) + } + + override fun showDonationCTAFromPositiveReviewDebug(): String? { + return getStringPreference(DEBUG_SHOW_DONATION_CTA_FROM_POSITIVE_REVIEW, null) + } + override fun setShowDonationCTAFromPositiveReviewDebug(show: String?) { + setStringPreference(DEBUG_SHOW_DONATION_CTA_FROM_POSITIVE_REVIEW, show) + } } diff --git a/app/src/main/java/org/session/libsession/utilities/Throttler.java b/app/src/main/java/org/session/libsession/utilities/Throttler.java deleted file mode 100644 index d5ac785bfe..0000000000 --- a/app/src/main/java/org/session/libsession/utilities/Throttler.java +++ /dev/null @@ -1,45 +0,0 @@ -package org.session.libsession.utilities; - -import android.os.Handler; - -/** - * A class that will throttle the number of runnables executed to be at most once every specified - * interval. - * - * Useful for performing actions in response to rapid user input where you want to take action on - * the initial input but prevent follow-up spam. - * - * This is different from {@link Debouncer} in that it will run the first runnable immediately - * instead of waiting for input to die down. - * - * See http://rxmarbles.com/#throttle - */ -public class Throttler { - - private static final int WHAT = 8675309; - - private final Handler handler; - private final long threshold; - - /** - * @param threshold Only one runnable will be executed via {@link #publish(Runnable)} every - * {@code threshold} milliseconds. - */ - public Throttler(long threshold) { - this.handler = new Handler(); - this.threshold = threshold; - } - - public void publish(Runnable runnable) { - if (handler.hasMessages(WHAT)) { - return; - } - - runnable.run(); - handler.sendMessageDelayed(handler.obtainMessage(WHAT), threshold); - } - - public void clear() { - handler.removeCallbacksAndMessages(null); - } -} diff --git a/app/src/main/java/org/session/libsession/utilities/Util.kt b/app/src/main/java/org/session/libsession/utilities/Util.kt index 50215e321c..3f691efcf4 100644 --- a/app/src/main/java/org/session/libsession/utilities/Util.kt +++ b/app/src/main/java/org/session/libsession/utilities/Util.kt @@ -201,11 +201,6 @@ object Util { } } - @JvmStatic - fun isOwnNumber(context: Context, number: String): Boolean { - return TextSecurePreferences.getLocalNumber(context).equals(number) - } - @JvmStatic fun partition(list: List, partitionSize: Int): List> { val results: MutableList> = LinkedList() diff --git a/app/src/main/java/org/session/libsession/utilities/WindowDebouncer.kt b/app/src/main/java/org/session/libsession/utilities/WindowDebouncer.kt deleted file mode 100644 index bec5c93837..0000000000 --- a/app/src/main/java/org/session/libsession/utilities/WindowDebouncer.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.session.libsession.utilities - -import java.util.Timer -import java.util.TimerTask -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicReference - -/** - * Not really a 'debouncer' but named to be similar to the current Debouncer - * designed to queue tasks on a window (if not already queued) like a timer - */ -class WindowDebouncer(private val timeWindowMilliseconds: Long, private val timer: Timer) { - - private val atomicRef: AtomicReference = AtomicReference(null) - private val hasStarted = AtomicBoolean(false) - - private val recursiveRunnable: TimerTask = object:TimerTask() { - override fun run() { - val runnable = atomicRef.getAndSet(null) - runnable?.run() - } - } - - fun publish(runnable: Runnable) { - if (hasStarted.compareAndSet(false, true)) { - timer.scheduleAtFixedRate(recursiveRunnable, 0, timeWindowMilliseconds) - } - atomicRef.compareAndSet(null, runnable) - } - -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/utilities/concurrent/AssertedSuccessListener.java b/app/src/main/java/org/session/libsession/utilities/concurrent/AssertedSuccessListener.java deleted file mode 100644 index 391f53d027..0000000000 --- a/app/src/main/java/org/session/libsession/utilities/concurrent/AssertedSuccessListener.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.session.libsession.utilities.concurrent; - -import org.session.libsignal.utilities.ListenableFuture.Listener; - -import java.util.concurrent.ExecutionException; - -public abstract class AssertedSuccessListener implements Listener { - - @Override - public void onFailure(ExecutionException e) { - throw new AssertionError(e); - } -} diff --git a/app/src/main/java/org/session/libsession/utilities/recipients/ProStatus.kt b/app/src/main/java/org/session/libsession/utilities/recipients/ProStatus.kt deleted file mode 100644 index d59100869d..0000000000 --- a/app/src/main/java/org/session/libsession/utilities/recipients/ProStatus.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.session.libsession.utilities.recipients - -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonClassDiscriminator -import org.session.libsession.utilities.serializable.InstantAsMillisSerializer -import java.time.Instant - -@OptIn(ExperimentalSerializationApi::class) -@Serializable -@JsonClassDiscriminator("status") -sealed interface ProStatus { - @Serializable - @SerialName("pro") - data class Pro( - /** - * Whether the Pro badge should be visible or not. - */ - val visible: Boolean = true, - - /** - * The validity of the Pro status, if null, it means the Pro status is permanent. - */ - @Serializable(with = InstantAsMillisSerializer::class) - val validUntil: Instant? = null, - ) : ProStatus - - @Serializable - @SerialName("none") - data object None : ProStatus -} - -fun ProStatus.isPro(now: Instant = Instant.now()): Boolean { - return this is ProStatus.Pro && (validUntil == null || validUntil.isAfter(now)) -} - -fun ProStatus.shouldShowProBadge(now: Instant = Instant.now()): Boolean { - return isPro(now) && (this as ProStatus.Pro).visible -} diff --git a/app/src/main/java/org/session/libsession/utilities/recipients/Recipient.kt b/app/src/main/java/org/session/libsession/utilities/recipients/Recipient.kt index 25cb50b4f3..62ce248290 100644 --- a/app/src/main/java/org/session/libsession/utilities/recipients/Recipient.kt +++ b/app/src/main/java/org/session/libsession/utilities/recipients/Recipient.kt @@ -1,6 +1,6 @@ package org.session.libsession.utilities.recipients -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED +import network.loki.messenger.libsession_util.PRIORITY_PINNED import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.messaging.open_groups.GroupMemberRole import org.session.libsession.utilities.Address @@ -37,7 +37,7 @@ data class Recipient( * it will always return [GroupMemberRole.STANDARD]. */ val currentUserRole: GroupMemberRole get() = when (data) { - is RecipientData.Group -> if (data.partial.isAdmin) GroupMemberRole.ADMIN else GroupMemberRole.STANDARD + is RecipientData.Group -> if (data.isAdmin) GroupMemberRole.ADMIN else GroupMemberRole.STANDARD is RecipientData.Community -> when { data.roomInfo?.admin == true -> GroupMemberRole.ADMIN data.roomInfo?.moderator == true -> GroupMemberRole.MODERATOR @@ -60,7 +60,7 @@ data class Recipient( val expiryMode: ExpiryMode get() = when (data) { is RecipientData.Self -> data.expiryMode is RecipientData.Contact -> data.expiryMode - is RecipientData.Group -> data.partial.expiryMode + is RecipientData.Group -> data.expiryMode else -> ExpiryMode.NONE } @@ -71,12 +71,13 @@ data class Recipient( address is Address.CommunityBlindedId -> true data is RecipientData.Contact -> data.approved - data is RecipientData.Group -> data.partial.approved + data is RecipientData.Group -> data.approved else -> false } - val proStatus: ProStatus get() = data.proStatus + val isPro: Boolean get() = data.proData != null + val shouldShowProBadge: Boolean get() = data.proData?.showProBadge == true val approvedMe: Boolean get() { return when (data) { diff --git a/app/src/main/java/org/session/libsession/utilities/recipients/RecipientData.kt b/app/src/main/java/org/session/libsession/utilities/recipients/RecipientData.kt index 8aa9948218..26506c7ca9 100644 --- a/app/src/main/java/org/session/libsession/utilities/recipients/RecipientData.kt +++ b/app/src/main/java/org/session/libsession/utilities/recipients/RecipientData.kt @@ -1,6 +1,6 @@ package org.session.libsession.utilities.recipients -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.GroupInfo import network.loki.messenger.libsession_util.util.GroupMember @@ -20,10 +20,9 @@ sealed interface RecipientData { val priority: Long val profileUpdatedAt: Instant? - val proStatus: ProStatus + val proData: ProData? - // Marker interface to distinguish between config-based and other recipient data. - sealed interface ConfigBased + fun setProData(proData: ProData): RecipientData /** * Represents a group-like recipient, which can be a group or community. @@ -50,19 +49,23 @@ sealed interface RecipientData { val displayName: String = "", override val avatar: RemoteFile? = null, override val priority: Long = PRIORITY_VISIBLE, - override val proStatus: ProStatus = ProStatus.None, + override val proData: ProData? = null, val acceptsBlindedCommunityMessageRequests: Boolean = false, override val profileUpdatedAt: Instant? = null, - ) : RecipientData + ) : RecipientData { + override fun setProData(proData: ProData): Generic = copy(proData = proData) + } data class BlindedContact( val displayName: String, override val avatar: RemoteFile.Encrypted?, override val priority: Long, - override val proStatus: ProStatus, + override val proData: ProData?, val acceptsBlindedCommunityMessageRequests: Boolean, override val profileUpdatedAt: Instant? - ) : ConfigBased, RecipientData + ) : RecipientData { + override fun setProData(proData: ProData): BlindedContact = copy(proData = proData) + } data class Community( val serverUrl: String, @@ -95,6 +98,11 @@ sealed interface RecipientData { override val profileUpdatedAt: Instant? get() = null + override val proData: ProData? + get() = null + + override fun setProData(proData: ProData): Community = this + override fun hasAdmin(user: AccountId): Boolean { return roomInfo != null && (roomInfo.details.admins.contains(user.hexString) || roomInfo.details.moderators.contains(user.hexString) || @@ -106,9 +114,6 @@ sealed interface RecipientData { return roomInfo != null && (roomInfo.details.admins.contains(user.hexString) || roomInfo.details.moderators.contains(user.hexString)) } - - override val proStatus: ProStatus - get() = ProStatus.None } /** @@ -119,9 +124,11 @@ sealed interface RecipientData { override val avatar: RemoteFile.Encrypted?, val expiryMode: ExpiryMode, override val priority: Long, - override val proStatus: ProStatus, + override val proData: ProData?, override val profileUpdatedAt: Instant? - ) : ConfigBased, RecipientData + ) : RecipientData { + override fun setProData(proData: ProData): Self = copy(proData = proData) + } /** * A recipient that was saved in your contact config. @@ -135,11 +142,13 @@ sealed interface RecipientData { val blocked: Boolean, val expiryMode: ExpiryMode, override val priority: Long, - override val proStatus: ProStatus, + override val proData: ProData?, override val profileUpdatedAt: Instant?, - ) : ConfigBased, RecipientData { + ) : RecipientData { val displayName: String get() = nickname?.takeIf { it.isNotBlank() } ?: name + + override fun setProData(proData: ProData): Contact = copy(proData = proData) } data class GroupMemberInfo( @@ -156,55 +165,40 @@ sealed interface RecipientData { ) } + /** - * Group data fetched from the config. It's named as "partial" because it does not include - * all the information we need to resemble a full group recipient, hence not implementing the - * [RecipientData] interface. + * Full group data that includes additional information that may not be present in the config. */ - data class PartialGroup( + data class Group( val name: String, private val groupInfo: GroupInfo.ClosedGroupInfo, - val avatar: RemoteFile.Encrypted?, + override val avatar: RemoteFile.Encrypted?, val expiryMode: ExpiryMode, - val proStatus: ProStatus, val members: List, val description: String?, - ) : ConfigBased { + override val proData: ProData?, + override val firstMember: Recipient?, // Used primarily to assemble the profile picture for the group. + override val secondMember: Recipient?, // Used primarily to assemble the profile picture for the group. + ) : RecipientData, GroupLike { val approved: Boolean get() = !groupInfo.invited - val priority: Long get() = groupInfo.priority + override val priority: Long get() = groupInfo.priority val isAdmin: Boolean get() = groupInfo.hasAdminKey() val kicked: Boolean get() = groupInfo.kicked val destroyed: Boolean get() = groupInfo.destroyed val shouldPoll: Boolean get() = groupInfo.shouldPoll - } - - /** - * Full group data that includes additional information that may not be present in the config. - */ - data class Group( - val partial: PartialGroup, - override val firstMember: Recipient, // Used primarily to assemble the profile picture for the group. - override val secondMember: Recipient?, // Used primarily to assemble the profile picture for the group. - ) : RecipientData, GroupLike { - override val avatar: RemoteFile? - get() = partial.avatar - - override val priority: Long - get() = partial.priority - - override val proStatus: ProStatus - get() = partial.proStatus override val profileUpdatedAt: Instant? get() = null override fun hasAdmin(user: AccountId): Boolean { - return partial.members.any { it.address.accountId == user && it.isAdmin } + return members.any { it.address.accountId == user && it.isAdmin } } override fun shouldShowAdminCrown(user: AccountId): Boolean { return hasAdmin(user) } + + override fun setProData(proData: ProData): Group = copy(proData = proData) } data class LegacyGroup( @@ -218,9 +212,6 @@ sealed interface RecipientData { override val avatar: RemoteFile? get() = null - override val proStatus: ProStatus - get() = ProStatus.None - override fun hasAdmin(user: AccountId): Boolean { return members[user]?.canModerate == true } @@ -231,5 +222,15 @@ sealed interface RecipientData { override val profileUpdatedAt: Instant? get() = null + + override val proData: ProData? + get() = null + + override fun setProData(proData: ProData): LegacyGroup = this } + + + data class ProData( + val showProBadge: Boolean, + ) } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/utilities/recipients/RecipientModule.kt b/app/src/main/java/org/session/libsession/utilities/recipients/RecipientModule.kt deleted file mode 100644 index 5b55a50273..0000000000 --- a/app/src/main/java/org/session/libsession/utilities/recipients/RecipientModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.session.libsession.utilities.recipients - -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import dagger.multibindings.IntoSet -import kotlinx.serialization.modules.SerializersModule -import kotlinx.serialization.modules.polymorphic -import kotlinx.serialization.modules.subclass - -@Module -@InstallIn(SingletonComponent::class) -class RecipientModule { - @Provides - @IntoSet - fun provideProStatusSerializer(): SerializersModule = SerializersModule { - polymorphic(ProStatus::class) { - subclass(ProStatus.Pro::class) - subclass(ProStatus.None::class) - } - } -} diff --git a/app/src/main/java/org/session/libsession/utilities/recipients/RecipientNames.kt b/app/src/main/java/org/session/libsession/utilities/recipients/RecipientNames.kt index e3791530b9..e33d6864c8 100644 --- a/app/src/main/java/org/session/libsession/utilities/recipients/RecipientNames.kt +++ b/app/src/main/java/org/session/libsession/utilities/recipients/RecipientNames.kt @@ -17,7 +17,7 @@ fun Recipient.displayName( is RecipientData.Self -> data.name is RecipientData.Contact -> data.displayName is RecipientData.LegacyGroup -> data.name - is RecipientData.Group -> data.partial.name + is RecipientData.Group -> data.name is RecipientData.Generic -> data.displayName is RecipientData.Community -> data.roomInfo?.details?.name ?: data.room is RecipientData.BlindedContact -> data.displayName diff --git a/app/src/main/java/org/session/libsession/utilities/recipients/RecipientProStatuses.kt b/app/src/main/java/org/session/libsession/utilities/recipients/RecipientProStatuses.kt deleted file mode 100644 index 9467121461..0000000000 --- a/app/src/main/java/org/session/libsession/utilities/recipients/RecipientProStatuses.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.session.libsession.utilities.recipients - -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.transform - -/** - * Transform the flow into emitting the same recipient whenever the pro status is about to change - * according to its validity period. - */ -fun Flow.repeatedWithEffectiveProStatusChange(): Flow { - return transform { r -> - emit(r) - if (r.proStatus is ProStatus.Pro) { - val validUntil = (r.proStatus as ProStatus.Pro).validUntil - if (validUntil != null) { - val expirationTime = validUntil.toEpochMilli() - System.currentTimeMillis() - delay(expirationTime) - } - } - emit(r) - } -} diff --git a/app/src/main/java/org/session/libsession/utilities/serializable/ByteArrayAsHexSerializer.kt b/app/src/main/java/org/session/libsession/utilities/serializable/ByteArrayAsHexSerializer.kt new file mode 100644 index 0000000000..685c190e50 --- /dev/null +++ b/app/src/main/java/org/session/libsession/utilities/serializable/ByteArrayAsHexSerializer.kt @@ -0,0 +1,26 @@ +package org.session.libsession.utilities.serializable + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.toHexString + +class ByteArrayAsHexSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor(javaClass.name, PrimitiveKind.STRING) + + override fun serialize( + encoder: Encoder, + value: ByteArray + ) { + encoder.encodeString(value.toHexString()) + } + + override fun deserialize(decoder: Decoder): ByteArray { + return decoder.decodeString().let(Hex::fromStringCondensed) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/utilities/serializable/BytesAsBase64Serializer.kt b/app/src/main/java/org/session/libsession/utilities/serializable/BytesAsBase64Serializer.kt new file mode 100644 index 0000000000..ff907b9bac --- /dev/null +++ b/app/src/main/java/org/session/libsession/utilities/serializable/BytesAsBase64Serializer.kt @@ -0,0 +1,28 @@ +package org.session.libsession.utilities.serializable + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import network.loki.messenger.libsession_util.util.Bytes +import org.session.libsignal.utilities.Base64 + +class BytesAsBase64Serializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( + serialName = Bytes::javaClass.name, + kind = PrimitiveKind.STRING + ) + + override fun serialize( + encoder: Encoder, + value: Bytes + ) { + encoder.encodeString(Base64.encodeBytes(value.data)) + } + + override fun deserialize(decoder: Decoder): Bytes { + return Bytes(Base64.decode(decoder.decodeString())) + } +} diff --git a/app/src/main/java/org/session/libsession/utilities/serializable/HttpSerializer.kt b/app/src/main/java/org/session/libsession/utilities/serializable/HttpUrlSerializer.kt similarity index 94% rename from app/src/main/java/org/session/libsession/utilities/serializable/HttpSerializer.kt rename to app/src/main/java/org/session/libsession/utilities/serializable/HttpUrlSerializer.kt index ec2b0181b9..0a1bf4f61b 100644 --- a/app/src/main/java/org/session/libsession/utilities/serializable/HttpSerializer.kt +++ b/app/src/main/java/org/session/libsession/utilities/serializable/HttpUrlSerializer.kt @@ -9,7 +9,7 @@ import kotlinx.serialization.encoding.Encoder import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl -class HttpSerializer : KSerializer { +class HttpUrlSerializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( serialName = HttpUrl::javaClass.name, kind = PrimitiveKind.STRING diff --git a/app/src/main/java/org/session/libsession/utilities/serializable/KeyPairAsArraySerializer.kt b/app/src/main/java/org/session/libsession/utilities/serializable/KeyPairAsArraySerializer.kt new file mode 100644 index 0000000000..d30d4969e3 --- /dev/null +++ b/app/src/main/java/org/session/libsession/utilities/serializable/KeyPairAsArraySerializer.kt @@ -0,0 +1,32 @@ +package org.session.libsession.utilities.serializable + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ArraySerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import network.loki.messenger.libsession_util.util.KeyPair +import org.session.libsignal.utilities.Base64 + +class KeyPairAsArraySerializer : KSerializer { + private val stringArraySerializer: KSerializer> = ArraySerializer(String::class, String.serializer()) + override val descriptor = stringArraySerializer.descriptor + + override fun serialize( + encoder: Encoder, + value: KeyPair + ) { + stringArraySerializer.serialize(encoder, arrayOf( + Base64.encodeBytes(value.pubKey.data), + Base64.encodeBytes(value.secretKey.data) + )) + } + + override fun deserialize(decoder: Decoder): KeyPair { + val (pubKeyBase64, secretKeyBase64) = stringArraySerializer.deserialize(decoder) + return KeyPair( + pubKey = Base64.decode(pubKeyBase64), + secretKey = Base64.decode(secretKeyBase64) + ) + } +} diff --git a/app/src/main/java/org/session/libsignal/crypto/PushTransportDetails.java b/app/src/main/java/org/session/libsignal/crypto/PushTransportDetails.java index bdd9964d5e..536b1e13a4 100644 --- a/app/src/main/java/org/session/libsignal/crypto/PushTransportDetails.java +++ b/app/src/main/java/org/session/libsignal/crypto/PushTransportDetails.java @@ -8,6 +8,11 @@ import org.session.libsignal.utilities.Log; +/** + * @deprecated The logic here has been moved to SessionProtocol, this class only exists + * so the old persisted message queue can be read. It will be removed in a future release. + */ +@Deprecated(forRemoval = true) public class PushTransportDetails { private static final String TAG = PushTransportDetails.class.getSimpleName(); diff --git a/app/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt b/app/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt index 9d0d4a6241..c96bbce767 100644 --- a/app/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt +++ b/app/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt @@ -20,11 +20,6 @@ interface LokiAPIDatabaseProtocol { fun clearLastMessageHashes(publicKey: String) fun clearLastMessageHashesByNamespaces(vararg namespaces: Int) fun clearAllLastMessageHashes() - fun getReceivedMessageHashValues(publicKey: String, namespace: Int): Set? - fun setReceivedMessageHashValues(publicKey: String, newValue: Set, namespace: Int) - fun clearReceivedMessageHashValues(publicKey: String) - fun clearReceivedMessageHashValues() - fun clearReceivedMessageHashValuesByNamespaces(vararg namespaces: Int) fun getAuthToken(server: String): String? fun setAuthToken(server: String, newValue: String?) fun getLastMessageServerID(room: String, server: String): Long? @@ -35,7 +30,6 @@ interface LokiAPIDatabaseProtocol { fun setOpenGroupPublicKey(server: String, newValue: String) fun getLastSnodePoolRefreshDate(): Date? fun setLastSnodePoolRefreshDate(newValue: Date) - fun getUserX25519KeyPair(): ECKeyPair fun getClosedGroupEncryptionKeyPairs(groupPublicKey: String): List fun getLatestClosedGroupEncryptionKeyPair(groupPublicKey: String): ECKeyPair? fun isClosedGroup(groupPublicKey: String): Boolean diff --git a/app/src/main/java/org/session/libsignal/utilities/AccountId.kt b/app/src/main/java/org/session/libsignal/utilities/AccountId.kt index adce91c77b..bce895ba6c 100644 --- a/app/src/main/java/org/session/libsignal/utilities/AccountId.kt +++ b/app/src/main/java/org/session/libsignal/utilities/AccountId.kt @@ -24,6 +24,12 @@ data class AccountId( val prefix: IdPrefix? get() = IdPrefix.fromValue(hexString) + /** + * The public key bytes with the prefix removed. + * + * Note: the type of public key varies depending on the prefix. For STANDARD, + * it's an Curve25519 public key, otherwise, it should be an Ed25519 public key. + */ val pubKeyBytes: ByteArray by lazy { Hex.fromStringCondensed(hexString.drop(2)) } diff --git a/app/src/main/java/org/session/libsignal/utilities/KeyHelper.java b/app/src/main/java/org/session/libsignal/utilities/KeyHelper.java deleted file mode 100644 index f4fe56f332..0000000000 --- a/app/src/main/java/org/session/libsignal/utilities/KeyHelper.java +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Copyright (C) 2014-2016 Open Whisper Systems - * - * Licensed according to the LICENSE file in this repository. - */ -package org.session.libsignal.utilities; - -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; - -/** - * Helper class for generating keys of different types. - * - * @author Moxie Marlinspike - */ -public class KeyHelper { - - private KeyHelper() {} - /** - * Generate a registration ID. Clients should only do this once, - * at install time. - * - * @param extendedRange By default (false), the generated registration - * ID is sized to require the minimal possible protobuf - * encoding overhead. Specify true if the caller needs - * the full range of MAX_INT at the cost of slightly - * higher encoding overhead. - * @return the generated registration ID. - */ - public static int generateRegistrationId(boolean extendedRange) { - SecureRandom secureRandom = new SecureRandom(); - if (extendedRange) return secureRandom.nextInt(Integer.MAX_VALUE - 1) + 1; - else return secureRandom.nextInt(16380) + 1; - } -} diff --git a/app/src/main/java/org/session/libsignal/utilities/Validation.kt b/app/src/main/java/org/session/libsignal/utilities/Validation.kt index fdf9bd386f..eaa1fa1bab 100644 --- a/app/src/main/java/org/session/libsignal/utilities/Validation.kt +++ b/app/src/main/java/org/session/libsignal/utilities/Validation.kt @@ -4,8 +4,24 @@ object PublicKeyValidation { private val HEX_CHARACTERS = "0123456789ABCDEFabcdef".toSet() private val INVALID_PREFIXES = setOf(IdPrefix.GROUP, IdPrefix.BLINDED, IdPrefix.BLINDEDV2) - fun isValid(candidate: String, isPrefixRequired: Boolean = true): Boolean = hasValidLength(candidate) && isValidHexEncoding(candidate) && (!isPrefixRequired || IdPrefix.fromValue(candidate) != null) + fun isValid(candidate: String, isPrefixRequired: Boolean = true): Boolean { + if (!hasValidLength(candidate)) return false + + val prefix = IdPrefix.fromValue(candidate) + + // Handle invalid Account ID conditions + // Case 1: Standard prefix "05" but not valid hex + if (prefix == IdPrefix.STANDARD && !isValidHexEncoding(candidate)) return false + + // Case 2: Blinded or Group IDs should never be accepted as valid Account IDs + if (prefix in INVALID_PREFIXES) return false + + // Standard validity rules + return isValidHexEncoding(candidate) && + (!isPrefixRequired || prefix != null) + } + fun hasValidPrefix(candidate: String) = IdPrefix.fromValue(candidate) !in INVALID_PREFIXES - private fun hasValidLength(candidate: String) = candidate.length == 66 + fun hasValidLength(candidate: String) = candidate.length == 66 private fun isValidHexEncoding(candidate: String) = HEX_CHARACTERS.containsAll(candidate.toSet()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt index 1ebbedd2bb..09ff1eec29 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt @@ -50,7 +50,9 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.pushSuff import org.session.libsignal.utilities.HTTP.isConnectedToNetwork import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.AppContext.configureKovenant +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.debugmenu.DebugActivity +import org.thoughtcrime.securesms.debugmenu.DebugLogger import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseModule.init import org.thoughtcrime.securesms.dependencies.OnAppStartupComponents @@ -89,6 +91,7 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, Configuratio @Inject lateinit var startupComponents: Lazy @Inject lateinit var persistentLogger: Lazy + @Inject lateinit var debugLogger: Lazy @Inject lateinit var textSecurePreferences: Lazy @Inject lateinit var migrationManager: Lazy @@ -98,6 +101,9 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, Configuratio @Inject lateinit var remoteFileLoader: Provider + @Inject + lateinit var loginStateRepository: Lazy + @Volatile var isAppVisible: Boolean = false @@ -208,7 +214,7 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, Configuratio } private fun initializeLogging() { - Log.initialize(AndroidLogger(), persistentLogger.get()) + Log.initialize(AndroidLogger(), persistentLogger.get(), debugLogger.get()) Logger.addLogger(object : Logger { private val tag = "LibSession" diff --git a/app/src/main/java/org/thoughtcrime/securesms/InputBarDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/InputBarDialogs.kt index bb8daa7705..91d720e821 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/InputBarDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/InputBarDialogs.kt @@ -63,8 +63,9 @@ fun InputBarDialogs( } // Pro CTA - if (inputBarDialogsState.sessionProCharLimitCTA) { + if (inputBarDialogsState.sessionProCharLimitCTA != null) { LongMessageProCTA( + proSubscription = inputBarDialogsState.sessionProCharLimitCTA.proSubscription, onDismissRequest = {sendCommand(InputbarViewModel.Commands.HideSessionProCTA)} ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt index 74ba63918a..c5fdda1fb9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt @@ -7,10 +7,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.Util import org.session.libsession.utilities.StringSubstitutionConstants.LIMIT_KEY -import org.session.libsession.utilities.recipients.isPro -import org.session.libsession.utilities.recipients.shouldShowProBadge import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.ui.SimpleDialogData import org.thoughtcrime.securesms.util.NumberUtil @@ -29,12 +29,12 @@ abstract class InputbarViewModel( private val _inputBarStateDialogsState = MutableStateFlow(InputBarDialogsState()) val inputBarStateDialogsState: StateFlow = _inputBarStateDialogsState - val currentUserProStatus by lazy { recipientRepository.getSelf().proStatus } + private val currentUser by lazy { recipientRepository.getSelf() } fun onTextChanged(text: CharSequence) { // check the character limit - val maxChars = proStatusManager.getCharacterLimit(currentUserProStatus) - val charsLeft = maxChars - text.length + val maxChars = proStatusManager.getCharacterLimit(currentUser.isPro) + val charsLeft = maxChars - Util.countCodepoints(text.toString()) // update the char limit state based on characters left val charLimitState = if(charsLeft <= CHARACTER_LIMIT_THRESHOLD){ @@ -42,7 +42,7 @@ abstract class InputbarViewModel( count = charsLeft, countFormatted = NumberUtil.getFormattedNumber(charsLeft.toLong()), danger = charsLeft < 0, - showProBadge = proStatusManager.isPostPro() && currentUserProStatus.shouldShowProBadge() // only show the badge for non pro users POST pro launch + showProBadge = proStatusManager.isPostPro() && currentUser.shouldShowProBadge // only show the badge for non pro users POST pro launch ) } else { null @@ -58,7 +58,7 @@ abstract class InputbarViewModel( // the user is trying to send a message that is too long - we should display a dialog // we currently have different logic for PRE and POST Pro launch // which we can remove once Pro is out - currently we can switch this fro the debug menu - if(!proStatusManager.isPostPro() || currentUserProStatus.isPro()){ + if(!proStatusManager.isPostPro() || currentUser.isPro){ showMessageTooLongSendDialog() } else { showSessionProCTA() @@ -73,7 +73,7 @@ abstract class InputbarViewModel( fun onCharLimitTapped(){ // we currently have different logic for PRE and POST Pro launch // which we can remove once Pro is out - currently we can switch this fro the debug menu - if(!proStatusManager.isPostPro() || currentUserProStatus.isPro()){ + if(!proStatusManager.isPostPro() || currentUser.isPro){ handleCharLimitTappedForProUser() } else { handleCharLimitTappedForRegularUser() @@ -94,7 +94,7 @@ abstract class InputbarViewModel( fun showSessionProCTA(){ _inputBarStateDialogsState.update { - it.copy(sessionProCharLimitCTA = true) + it.copy(sessionProCharLimitCTA = CharLimitCTAData(proStatusManager.proDataState.value.type)) } } @@ -107,7 +107,7 @@ abstract class InputbarViewModel( message = application.resources.getQuantityString( R.plurals.modalMessageCharacterDisplayDescription, charsLeft, // quantity for plural - proStatusManager.getCharacterLimit(currentUserProStatus), // 1st arg: total character limit + proStatusManager.getCharacterLimit(currentUser.isPro), // 1st arg: total character limit charsLeft, // 2nd arg: chars left ), positiveStyleDanger = false, @@ -125,7 +125,7 @@ abstract class InputbarViewModel( showSimpleDialog = SimpleDialogData( title = application.getString(R.string.modalMessageTooLongTitle), message = Phrase.from(application.getString(R.string.modalMessageCharacterTooLongDescription)) - .put(LIMIT_KEY, proStatusManager.getCharacterLimit(currentUserProStatus)) + .put(LIMIT_KEY, proStatusManager.getCharacterLimit(currentUser.isPro)) .format(), positiveStyleDanger = false, positiveText = application.getString(R.string.okay), @@ -141,7 +141,7 @@ abstract class InputbarViewModel( showSimpleDialog = SimpleDialogData( title = application.getString(R.string.modalMessageTooLongTitle), message = Phrase.from(application.getString(R.string.modalMessageTooLongDescription)) - .put(LIMIT_KEY, proStatusManager.getCharacterLimit(currentUserProStatus)) + .put(LIMIT_KEY, proStatusManager.getCharacterLimit(currentUser.isPro)) .format(), positiveStyleDanger = false, positiveText = application.getString(R.string.okay), @@ -165,7 +165,7 @@ abstract class InputbarViewModel( is Commands.HideSessionProCTA -> { _inputBarStateDialogsState.update { - it.copy(sessionProCharLimitCTA = false) + it.copy(sessionProCharLimitCTA = null) } } } @@ -195,7 +195,11 @@ abstract class InputbarViewModel( data class InputBarDialogsState( val showSimpleDialog: SimpleDialogData? = null, - val sessionProCharLimitCTA: Boolean = false + val sessionProCharLimitCTA: CharLimitCTAData? = null + ) + + data class CharLimitCTAData( + val proSubscription: ProStatus ) sealed interface Commands { diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt index 93d5841385..a3444934fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt @@ -71,7 +71,7 @@ import network.loki.messenger.databinding.MediaViewPageBinding import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.messages.control.DataExtractionNotification import org.session.libsession.messaging.messages.control.DataExtractionNotification.Kind.MediaSaved -import org.session.libsession.messaging.sending_receiving.MessageSender.send +import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.snode.SnodeAPI.nowWithOffset import org.session.libsession.utilities.Address @@ -136,6 +136,9 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), @Inject lateinit var recipientRepository: RecipientRepository + @Inject + lateinit var messageSender: MessageSender + override val applyDefaultWindowInsets: Boolean get() = false @@ -552,7 +555,7 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), nowWithOffset ) ) - send(message, conversationAddress!!) + messageSender.send(message, conversationAddress!!) } @SuppressLint("StaticFieldLeak") diff --git a/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActionBarActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActionBarActivity.kt index c1125f7fba..e35fb2034f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActionBarActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActionBarActivity.kt @@ -16,7 +16,6 @@ import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber import org.session.libsession.utilities.TextSecurePreferences.Companion.isScreenLockEnabled import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.home.HomeActivity @@ -84,7 +83,8 @@ abstract class ScreenLockActionBarActivity : BaseActionBarActivity() { override fun onCreate(savedInstanceState: Bundle?) { Log.i(TAG, "ScreenLockActionBarActivity.onCreate(" + savedInstanceState + ")") - val locked = KeyCachingService.isLocked(this) && isScreenLockEnabled(this) && getLocalNumber(this) != null + val locked = KeyCachingService.isLocked(this) && isScreenLockEnabled(this) && + (applicationContext as ApplicationContext).loginStateRepository.get().peekLoginState() != null routeApplicationState(locked) super.onCreate(savedInstanceState) @@ -166,7 +166,7 @@ abstract class ScreenLockActionBarActivity : BaseActionBarActivity() { private fun getApplicationState(locked: Boolean): Int { return if (migrationManager.migrationState.value.shouldShowUI) { STATE_DATABASE_MIGRATE - } else if (getLocalNumber(this) == null) { + } else if ((applicationContext as ApplicationContext).loginStateRepository.get().peekLoginState() == null) { STATE_WELCOME_SCREEN } else if (locked) { STATE_SCREEN_LOCKED diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShareViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/ShareViewModel.kt index 4b3b3333a3..c500c40ce2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ShareViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ShareViewModel.kt @@ -23,17 +23,15 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import network.loki.messenger.R -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN +import network.loki.messenger.libsession_util.PRIORITY_HIDDEN import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.RecipientData -import org.session.libsession.utilities.recipients.shouldShowProBadge import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.home.search.searchName import org.thoughtcrime.securesms.mms.PartAuthority -import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.providers.BlobUtils import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.util.AvatarUIData @@ -47,9 +45,8 @@ import javax.inject.Inject class ShareViewModel @Inject constructor( @param:ApplicationContext private val context: Context, private val avatarUtils: AvatarUtils, - private val proStatusManager: ProStatusManager, private val deprecationManager: LegacyGroupDeprecationManager, - private val conversationRepository: ConversationRepository, + conversationRepository: ConversationRepository, ): ViewModel(){ private val TAG = ShareViewModel::class.java.simpleName @@ -123,7 +120,7 @@ class ShareViewModel @Inject constructor( else recipient.searchName, address = recipient.address, avatarUIData = avatarUtils.getUIDataFromRecipient(recipient), - showProBadge = recipient.proStatus.shouldShowProBadge() + showProBadge = recipient.shouldShowProBadge ) }.toList() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarDownloadManager.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarDownloadManager.kt index e013b5f259..ac2bffe579 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarDownloadManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarDownloadManager.kt @@ -24,6 +24,7 @@ import org.session.libsignal.utilities.ByteArraySlice.Companion.view import org.session.libsignal.utilities.ByteArraySlice.Companion.write import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.RecipientSettingsDatabase import org.thoughtcrime.securesms.util.DateUtils.Companion.millsToInstant import org.thoughtcrime.securesms.util.getRootCause @@ -47,6 +48,7 @@ class AvatarDownloadManager @Inject constructor( private val configFactory: ConfigFactoryProtocol, private val fileServerApi: FileServerApi, private val attachmentProcessor: AttachmentProcessor, + private val loginStateRepository: LoginStateRepository, ) { /** * A map of mutexes to synchronize downloads for each remote file. @@ -168,7 +170,7 @@ class AvatarDownloadManager @Inject constructor( if (configs.userProfile.getPic().url == profilePicUrl) { // If the profile picture URL matches the one in the user config, add the local number // as a recipient as well. - add(Address.fromSerialized(prefs.getLocalNumber()!!)) + add(Address.fromSerialized(loginStateRepository.requireLocalNumber())) } // Search through all contacts to find any that have this profile picture URL. @@ -212,7 +214,7 @@ class AvatarDownloadManager @Inject constructor( .getOrNull() ?: continue val meta = FileMetadata( - expiryTime = if (address.address == prefs.getLocalNumber()) { + expiryTime = if (address.address == loginStateRepository.requireLocalNumber()) { TextSecurePreferences.getProfileExpiry(context).millsToInstant() } else { null diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt index ac6369708a..0c1aca9c25 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.attachments import android.content.Context -import android.widget.Toast import androidx.compose.ui.unit.IntSize import androidx.hilt.work.HiltWorker import androidx.work.BackoffPolicy @@ -17,8 +16,6 @@ import dagger.Lazy import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import network.loki.messenger.BuildConfig import okhttp3.HttpUrl.Companion.toHttpUrl import okio.BufferedSource @@ -31,8 +28,8 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemoteFile import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.util.BitmapUtil -import org.thoughtcrime.securesms.util.CurrentActivityObserver import org.thoughtcrime.securesms.util.DateUtils.Companion.secondsToInstant import org.thoughtcrime.securesms.util.ImageUtils import java.time.Duration @@ -54,23 +51,14 @@ class AvatarReuploadWorker @AssistedInject constructor( private val configFactory: ConfigFactoryProtocol, private val avatarUploadManager: Lazy, private val localEncryptedFileInputStreamFactory: LocalEncryptedFileInputStream.Factory, - private val fileServerApi: FileServerApi, - private val prefs: TextSecurePreferences, - private val currentActivityObserver: CurrentActivityObserver, + private val fileServerApi: FileServerApi ) : CoroutineWorker(context, params) { /** * Log the given message and show a toast if in debug mode */ - private suspend inline fun logAndToast(message: String, e: Throwable? = null) { - Log.d(TAG, message, e) - - val context = currentActivityObserver.currentActivity.value ?: return - if (prefs.debugAvatarReupload || BuildConfig.DEBUG) { - withContext(Dispatchers.Main) { - Toast.makeText(context, "AvatarReupload[debug only]: $message", Toast.LENGTH_SHORT).show() - } - } + private fun log(message: String, e: Throwable? = null) { + Log.d(DebugLogGroup.AVATAR.label, "Avatar Reupload: $message", e) } override suspend fun doWork(): Result { @@ -79,13 +67,13 @@ class AvatarReuploadWorker @AssistedInject constructor( } if (profile == null) { - logAndToast("No profile picture set; nothing to do.") + log("No profile picture set; nothing to do.") return Result.success() } val localFile = AvatarDownloadManager.computeFileName(context, profile) if (!localFile.exists()) { - logAndToast("Avatar file is missing locally; nothing to do.") + log("Avatar file is missing locally; nothing to do.") return Result.success() } @@ -94,7 +82,7 @@ class AvatarReuploadWorker @AssistedInject constructor( // Check if the file exists and whether we need to do reprocessing, if we do, we reprocess and re-upload localEncryptedFileInputStreamFactory.create(localFile).use { stream -> if (stream.meta.hasPermanentDownloadError) { - logAndToast("Permanent download error for current avatar; nothing to do.") + log("Permanent download error for current avatar; nothing to do.") return Result.success() } @@ -103,7 +91,7 @@ class AvatarReuploadWorker @AssistedInject constructor( val source = stream.source().buffer() if ((lastUpdated != null && needsReProcessing(source)) || lastUpdated == null) { - logAndToast("About to start reuploading avatar.") + log("About to start reuploading avatar.") val attachment = attachmentProcessor.processAvatar( data = source.use { it.readByteArray() }, ) ?: return Result.failure() @@ -118,14 +106,14 @@ class AvatarReuploadWorker @AssistedInject constructor( } catch (e: CancellationException) { throw e } catch (e: NonRetryableException) { - logAndToast("Non-retryable error while reuploading avatar.", e) + log("Non-retryable error while reuploading avatar.", e) return Result.failure() } catch (e: Exception) { - logAndToast("Error while reuploading avatar.", e) + log("Error while reuploading avatar.", e) return Result.retry() } - logAndToast("Successfully reuploaded avatar.") + log("Successfully reuploaded avatar.") return Result.success() } } @@ -133,7 +121,7 @@ class AvatarReuploadWorker @AssistedInject constructor( // Otherwise, we only need to renew the same avatar on the server val parsed = fileServerApi.parseAttachmentUrl(profile.url.toHttpUrl()) - logAndToast("Renewing user avatar on ${parsed.fileServer}") + log("Renewing user avatar on ${parsed.fileServer}") try { fileServerApi.renew( fileId = parsed.fileId, @@ -149,7 +137,7 @@ class AvatarReuploadWorker @AssistedInject constructor( val now = Instant.now() if (fileExpiry?.isBefore(now) == true || (lastUpdated?.isBefore(now.minus(Duration.ofDays(12)))) == true) { - logAndToast("FileServer renew failed, trying to upload", e) + log("FileServer renew failed, trying to upload", e) val pictureData = localEncryptedFileInputStreamFactory.create(localFile).use { stream -> check(!stream.meta.hasPermanentDownloadError) { @@ -166,18 +154,18 @@ class AvatarReuploadWorker @AssistedInject constructor( } catch (e: CancellationException) { throw e } catch (e: Exception) { - logAndToast("Error while reuploading avatar after renew failed.", e) + log("Error while reuploading avatar after renew failed.", e) return Result.failure() } - logAndToast("Successfully reuploaded avatar after renew failed.") + log("Successfully reuploaded avatar after renew failed.") } else { - logAndToast( "Not reuploading avatar after renew failed; last updated too recent.") + log( "Not reuploading avatar after renew failed; last updated too recent.") } return Result.success() } else { - logAndToast("Error while renewing avatar. Retrying...", e) + log("Error while renewing avatar. Retrying...", e) return Result.retry() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt index b4bd8db154..eeab8b827e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt @@ -21,6 +21,8 @@ import org.session.libsession.utilities.Util import org.session.libsession.utilities.recipients.RemoteFile import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemoteFile import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.auth.LoginStateRepository +import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.util.castAwayType @@ -42,12 +44,13 @@ class AvatarUploadManager @Inject constructor( private val localEncryptedFileOutputStreamFactory: LocalEncryptedFileOutputStream.Factory, private val fileServerApi: FileServerApi, private val attachmentProcessor: AttachmentProcessor, + loginStateRepository: LoginStateRepository, ) : OnAppStartupComponent { init { // Manage scheduling/cancellation of the AvatarReuploadWorker based on login state scope.launch { combine( - prefs.watchLocalNumber() + loginStateRepository.loggedInState .map { it != null } .distinctUntilChanged(), TextSecurePreferences._events.filter { it == TextSecurePreferences.DEBUG_AVATAR_REUPLOAD } @@ -99,7 +102,7 @@ class AvatarUploadManager @Inject constructor( customExpiresDuration = DEBUG_AVATAR_TTL.takeIf { prefs.forcedShortTTL() } ) - Log.d(TAG, "Avatar upload finished with $uploadResult") + Log.d(DebugLogGroup.AVATAR.label, "Avatar upload finished with $uploadResult") val remoteFile = RemoteFile.Encrypted(url = uploadResult.fileUrl, key = Bytes(result.key)) @@ -111,7 +114,7 @@ class AvatarUploadManager @Inject constructor( it.write(pictureData) } - Log.d(TAG, "Avatar file written to local storage") + Log.d(DebugLogGroup.AVATAR.label, "Avatar file written to local storage") // Now that we have the file both locally and remotely, we can update the user profile val oldPic = configFactory.withMutableUserConfigs { @@ -134,7 +137,7 @@ class AvatarUploadManager @Inject constructor( // If we had an old avatar, delete it from local storage val oldFile = AvatarDownloadManager.computeFileName(application, oldPic) if (oldFile.exists()) { - Log.d(TAG, "Deleting old avatar file: $oldFile") + Log.d(DebugLogGroup.AVATAR.label, "Deleting old avatar file: $oldFile") oldFile.delete() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt index 1a293e2199..b737adc018 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.mms.MediaStream import org.thoughtcrime.securesms.mms.PartAuthority @@ -108,7 +109,8 @@ class DatabaseAttachmentProvider @Inject constructor( } override fun getLinkPreviewAttachmentIDFor(mmsMessageId: Long): Long? { - val message = mmsDatabase.getOutgoingMessage(mmsMessageId) + val message = mmsSmsDatabase.getMessageById(MessageId(mmsMessageId, true)) + as? MmsMessageRecord ?: return null return message.linkPreviews.firstOrNull()?.attachmentId?.rowId } diff --git a/app/src/main/java/org/thoughtcrime/securesms/auth/LoggedInState.kt b/app/src/main/java/org/thoughtcrime/securesms/auth/LoggedInState.kt new file mode 100644 index 0000000000..539ac1113b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/auth/LoggedInState.kt @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.auth + +import kotlinx.serialization.Serializable +import network.loki.messenger.libsession_util.Curve25519 +import network.loki.messenger.libsession_util.ED25519 +import network.loki.messenger.libsession_util.util.Bytes +import network.loki.messenger.libsession_util.util.KeyPair +import org.session.libsession.utilities.serializable.BytesAsBase64Serializer +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.IdPrefix +import java.security.SecureRandom + +@Serializable +data class LoggedInState( + val seeded: Seeded, + + @Serializable(with = BytesAsBase64Serializer::class) + val notificationKey: Bytes, +) { + init { + check(notificationKey.data.size == NOTIFICATION_KEY_LENGTH) { + "Notification key must be $NOTIFICATION_KEY_LENGTH bytes" + } + } + + val accountEd25519KeyPair: KeyPair get() = seeded.accountEd25519KeyPair + val accountX25519KeyPair: KeyPair get() = seeded.accountX25519KeyPair + val accountId: AccountId get() = seeded.accountId + + + /** + * Holds the account seed. Almost all account related keys are derived from this seed. + */ + @Serializable + data class Seeded( + @Serializable(with = BytesAsBase64Serializer::class) + val seed: Bytes + ) { + init { + check(seed.data.size == SEED_LENGTH) { + "Account seed must be $SEED_LENGTH bytes" + } + } + + private val paddedSeed: ByteArray by lazy(LazyThreadSafetyMode.NONE) { + seed.data + ByteArray(16) + } + + val accountEd25519KeyPair: KeyPair by lazy(LazyThreadSafetyMode.NONE) { + ED25519.generate(paddedSeed) + } + + val accountX25519KeyPair: KeyPair by lazy(LazyThreadSafetyMode.NONE) { + Curve25519.fromED25519(accountEd25519KeyPair) + } + + val accountId: AccountId by lazy(LazyThreadSafetyMode.NONE) { + AccountId(IdPrefix.STANDARD, accountX25519KeyPair.pubKey.data) + } + + val proMasterPrivateKey: ByteArray by lazy(LazyThreadSafetyMode.NONE) { + ED25519.generateProMasterKey(paddedSeed) + } + + override fun toString(): String { + return "Seeded(id=$accountId)" + } + } + + + override fun toString(): String { + return "LoggedInState(accountId=$accountId)" + } + + companion object { + private const val SEED_LENGTH = 16 + private const val NOTIFICATION_KEY_LENGTH = 32 + + + fun generate(seed: ByteArray?): LoggedInState { + return LoggedInState( + seeded = Seeded( + seed = Bytes(seed ?: (ByteArray(SEED_LENGTH).apply { + SecureRandom().nextBytes(this) + })) + ), + notificationKey = Bytes(ByteArray(NOTIFICATION_KEY_LENGTH).apply { + SecureRandom().nextBytes(this) + }), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/auth/LoginStateRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/auth/LoginStateRepository.kt new file mode 100644 index 0000000000..f8279dced4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/auth/LoginStateRepository.kt @@ -0,0 +1,207 @@ +package org.thoughtcrime.securesms.auth + +import android.content.Context +import android.content.SharedPreferences +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import network.loki.messenger.libsession_util.util.Bytes +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil +import org.thoughtcrime.securesms.crypto.KeyStoreHelper +import org.thoughtcrime.securesms.dependencies.ManagerScope +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Repository for managing the login state of the user. + * Persists the state securely using Android's Keystore system. + */ +@Singleton +class LoginStateRepository @Inject constructor( + @ApplicationContext context: Context, + private val json: Json, + @param:ManagerScope private val scope: CoroutineScope, +) { + private val sharedPrefs = context.getSharedPreferences("login_state", Context.MODE_PRIVATE) + + private val mutableLoggedInState: MutableStateFlow + + + init { + var initialState = sharedPrefs.getString(PREF_KEY_STATE, null)?.let { serializedState -> + runCatching { + json.decodeFromString( + KeyStoreHelper.unseal(KeyStoreHelper.SealedData.fromString(serializedState)).toString( + Charsets.UTF_8) + ) + + }.onFailure { + Log.e(TAG, "Unable to unseal login state", it) + }.getOrNull() + } + + if (initialState == null) { + initialState = runCatching { + // Can we load the state from the legacy format? + IdentityKeyUtil.checkUpdate(context) + val seed = IdentityKeyUtil.retrieve(context, "loki_seed")?.let(Hex::fromStringCondensed)?.let(::Bytes) + + if (seed != null) { + val existingNotificationKey = runCatching { + IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY) + ?.let(Hex::fromStringCondensed)?.let(::Bytes) + }.onFailure { e -> + Log.e(TAG, "Unable to retrieve legacy notification key. Regenerating", e) + }.getOrNull() + + val generated = LoggedInState.generate(seed = seed.data) + + if (existingNotificationKey != null) { + generated.copy(notificationKey = existingNotificationKey) + } else { + generated + } + } else { + null + } + }.onFailure { + Log.e(TAG, "Unable to load legacy login state", it) + }.getOrNull() + + + if (initialState != null) { + // Migrate legacy state to new format + Log.i(TAG, "Migrating legacy login state to new format") + saveLoggedInState(sharedPrefs, initialState, json) + + //TODO: Consider removing legacy data here after a grace period + } + } + + Log.d(TAG, "Loaded initial state: $initialState") + + mutableLoggedInState = MutableStateFlow(initialState) + + // Listen for changes to the login state and persist them + scope.launch { + mutableLoggedInState + .drop(1) // Skip the initial value + .collect { newState -> + if (newState != null) { + saveLoggedInState(sharedPrefs, newState, json) + Log.d(TAG, "Persisted new login state: $newState") + } else { + sharedPrefs.edit() + .remove(PREF_KEY_STATE) + .apply() + Log.d(TAG, "Cleared login state") + } + } + } + } + + val loggedInState: StateFlow get() = mutableLoggedInState + + fun requireLocalAccountId(): AccountId = requireLoggedInState().accountId + + /** + * Returns the local number (account ID as hex string) of the logged-in user. + * Throws an exception if no user is logged in. + */ + fun requireLocalNumber(): String = requireLocalAccountId().hexString + + /** + * Returns the local number (account ID as hex string) of the logged-in user, + */ + fun getLocalNumber(): String? = loggedInState.value?.accountId?.hexString + + /** + * Returns the current [LoggedInState] without observing for changes. + */ + fun peekLoginState(): LoggedInState? = loggedInState.value + + fun requireLoggedInState(): LoggedInState = requireNotNull(loggedInState.value) { + "No logged in user" + } + + /** + * A flow that starts emitting items from the provided [flowFactory] only when the user is logged in. + * If the user logs out, the previous flow is cancelled and no items are emitted until the user logs in again. + */ + fun flowWithLoggedInState(flowFactory: () -> Flow): Flow { + @Suppress("OPT_IN_USAGE") + return loggedInState + .map { it != null } + .distinctUntilChanged() + .flatMapLatest { loggedIn -> + if (loggedIn) { + flowFactory() + } else { + emptyFlow() + } + } + } + + /** + * Runs the provided [block] suspend function while the user is logged in, and cancels it + * when logged out. + */ + fun runWhileLoggedIn(scope: CoroutineScope, block: suspend () -> Unit) { + scope.launch { + loggedInState + .map { it != null } + .collectLatest { loggedIn -> + if (loggedIn) { + block() + } + } + } + } + + fun clear() { + mutableLoggedInState.value = null + } + + /** + * Updates the current [LoggedInState] using the provided [updater] function. + * The [org.thoughtcrime.securesms.auth.LoginStateRepository] will manage the persistence + * of the data automatically. You don't need to do anything else. + */ + fun update(updater: (LoggedInState?) -> LoggedInState) { + mutableLoggedInState.update(updater) + } + + + companion object { + private const val TAG = "LoginStateRepository" + + private const val PREF_KEY_STATE = "state" + + private fun saveLoggedInState( + prefs: SharedPreferences, + state: LoggedInState, + json: Json + ) { + prefs.edit() + .putString(PREF_KEY_STATE, + KeyStoreHelper.seal( + json.encodeToString(state).toByteArray(Charsets.UTF_8) + ).serialize()) + .apply() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TypingIndicatorPreferenceCompat.kt b/app/src/main/java/org/thoughtcrime/securesms/components/TypingIndicatorPreferenceCompat.kt new file mode 100644 index 0000000000..fc597a7894 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/TypingIndicatorPreferenceCompat.kt @@ -0,0 +1,81 @@ +package org.thoughtcrime.securesms.components + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView +import androidx.preference.PreferenceViewHolder +import androidx.preference.TwoStatePreference +import kotlinx.coroutines.flow.MutableStateFlow +import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorViewContainer +import org.thoughtcrime.securesms.ui.components.SessionSwitch +import org.thoughtcrime.securesms.ui.getSubbedCharSequence +import org.thoughtcrime.securesms.ui.setThemedContent + +class TypingIndicatorPreferenceCompat : TwoStatePreference { + private var listener: OnPreferenceClickListener? = null + + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs, androidx.preference.R.attr.switchPreferenceCompatStyle) + constructor(context: Context) : this(context, null, androidx.preference.R.attr.switchPreferenceCompatStyle) + + private val checkState = MutableStateFlow(isChecked) + private val enableState = MutableStateFlow(isEnabled) + + init { + widgetLayoutResource = R.layout.typing_indicator_preference + } + + override fun setChecked(checked: Boolean) { + super.setChecked(checked) + + checkState.value = checked + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + + enableState.value = enabled + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + + val composeView = holder.findViewById(R.id.compose_preference) as ComposeView + composeView.setThemedContent { + SessionSwitch( + checked = checkState.collectAsState().value, + onCheckedChange = null, + enabled = isEnabled + ) + } + + val typingView = holder.findViewById(R.id.pref_typing_indicator_view) as TypingIndicatorViewContainer + typingView.apply { + startAnimation() + + // stop animation if the preference row is detached + holder.itemView.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) = Unit + override fun onViewDetachedFromWindow(v: View) { + stopAnimation() + v.removeOnAttachStateChangeListener(this) + } + }) + } + } + + override fun setOnPreferenceClickListener(listener: OnPreferenceClickListener?) { + this.listener = listener + } + + override fun onClick() { + if (listener == null || !listener!!.onPreferenceClick(this)) { + super.onClick() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java index 22d9fe9310..567dc491c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java @@ -28,12 +28,14 @@ public class TypingStatusSender { private final Map selfTypingTimers; private final ThreadDatabase threadDatabase; private final RecipientRepository recipientRepository; + private final MessageSender messageSender; @Inject - public TypingStatusSender(ThreadDatabase threadDatabase, RecipientRepository recipientRepository) { + public TypingStatusSender(ThreadDatabase threadDatabase, RecipientRepository recipientRepository, MessageSender messageSender) { this.threadDatabase = threadDatabase; this.recipientRepository = recipientRepository; this.selfTypingTimers = new HashMap<>(); + this.messageSender = messageSender; } public synchronized void onTypingStarted(long threadId) { @@ -94,7 +96,7 @@ private void sendTyping(long threadId, boolean typingStarted) { } else { typingIndicator = new TypingIndicator(TypingIndicator.Kind.STOPPED); } - MessageSender.send(typingIndicator, recipient.getAddress()); + messageSender.send(typingIndicator, recipient.getAddress()); } private class StartRunnable implements Runnable { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.kt index 7c8ec46a5d..d5d52441d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components import android.annotation.SuppressLint import android.content.Context import android.graphics.PointF +import android.media.ExifInterface import android.net.Uri import android.os.AsyncTask import android.util.AttributeSet @@ -49,8 +50,31 @@ class ZoomingImageView @JvmOverloads constructor( this.photoView = findViewById(R.id.image_view) this.subsamplingImageView = findViewById(R.id.subsampling_image_view) + } + + private fun getSubsamplingOrientation(context: Context, uri: Uri): Int { + var exifOrientation = ExifInterface.ORIENTATION_UNDEFINED + try { + PartAuthority.getAttachmentStream(context, uri).use { input -> + val exif = ExifInterface(input) + exifOrientation = exif.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_UNDEFINED + ) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to read EXIF orientation", e) + } - subsamplingImageView.orientation = SubsamplingScaleImageView.ORIENTATION_USE_EXIF + return when (exifOrientation) { + ExifInterface.ORIENTATION_ROTATE_90, + ExifInterface.ORIENTATION_TRANSPOSE -> SubsamplingScaleImageView.ORIENTATION_90 + ExifInterface.ORIENTATION_ROTATE_180, + ExifInterface.ORIENTATION_FLIP_VERTICAL -> SubsamplingScaleImageView.ORIENTATION_180 + ExifInterface.ORIENTATION_ROTATE_270, + ExifInterface.ORIENTATION_TRANSVERSE -> SubsamplingScaleImageView.ORIENTATION_270 + else -> SubsamplingScaleImageView.ORIENTATION_0 + } } fun setInteractor(interactor: ZoomImageInteractions?) { @@ -62,39 +86,33 @@ class ZoomingImageView @JvmOverloads constructor( val context = context val maxTextureSize = BitmapUtil.getMaxTextureSize() - Log.i( - TAG, - "Max texture size: $maxTextureSize" - ) - - object : AsyncTask?>() { - override fun doInBackground(vararg params: Void?): Pair? { + object : AsyncTask() { + override fun doInBackground(vararg params: Void?): ImageLoadResult? { if (MediaUtil.isGif(contentType)) return null - try { - val inputStream = PartAuthority.getAttachmentStream(context, uri) - return BitmapUtil.getDimensions(inputStream) - } catch (e: IOException) { - Log.w(TAG, e) - return null - } catch (e: BitmapDecodingException) { + val dimStream = PartAuthority.getAttachmentStream(context, uri) + val dimensions = BitmapUtil.getDimensions(dimStream) + val orientation = getSubsamplingOrientation(context, uri) + + return ImageLoadResult(dimensions.first, dimensions.second, orientation) + } catch (e: Exception) { Log.w(TAG, e) return null } } - override fun onPostExecute(dimensions: Pair?) { + override fun onPostExecute(result: ImageLoadResult?) { Log.i( TAG, - "Dimensions: " + (if (dimensions == null) "(null)" else dimensions.first.toString() + ", " + dimensions.second) + "Dimensions: " + (if (result == null) "(null)" else "${result.width} , ${result.height} - orientation: ${result.orientation}") ) - if (dimensions == null || (dimensions.first <= maxTextureSize && dimensions.second <= maxTextureSize)) { + if (result == null || (result.width <= maxTextureSize && result.height <= maxTextureSize)) { Log.i(TAG, "Loading in standard image view...") setImageViewUri(glideRequests, uri) } else { Log.i(TAG, "Loading in subsampling image view...") - setSubsamplingImageViewUri(uri) + setSubsamplingImageViewUri(uri, result.orientation) } } }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) @@ -116,13 +134,14 @@ class ZoomingImageView @JvmOverloads constructor( } @SuppressLint("ClickableViewAccessibility") - private fun setSubsamplingImageViewUri(uri: Uri) { + private fun setSubsamplingImageViewUri(uri: Uri, orientation: Int) { subsamplingImageView.setBitmapDecoderFactory(AttachmentBitmapDecoderFactory()) subsamplingImageView.setRegionDecoderFactory(AttachmentRegionDecoderFactory()) - subsamplingImageView.visibility = VISIBLE photoView.visibility = GONE + subsamplingImageView.orientation = orientation + val gestureDetector = GestureDetector( context, object : GestureDetector.SimpleOnGestureListener() { @@ -132,7 +151,6 @@ class ZoomingImageView @JvmOverloads constructor( } } ) - subsamplingImageView.setImage(ImageSource.uri(uri)) subsamplingImageView.setOnTouchListener { v, event -> gestureDetector.onTouchEvent(event) @@ -158,6 +176,8 @@ class ZoomingImageView @JvmOverloads constructor( } } + data class ImageLoadResult(val width: Int, val height: Int, val orientation: Int) + companion object { private val TAG: String = ZoomingImageView::class.java.simpleName } diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt index 4fae3a59fb..e0c5e46541 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt @@ -8,12 +8,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.libsession_util.ReadableGroupInfoConfig @@ -39,6 +35,7 @@ import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.CommunityDatabase import org.thoughtcrime.securesms.database.DraftDatabase import org.thoughtcrime.securesms.database.GroupDatabase @@ -47,6 +44,7 @@ import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.database.RecipientSettingsDatabase import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase @@ -59,7 +57,6 @@ import org.thoughtcrime.securesms.util.castAwayType import java.util.EnumSet import java.util.concurrent.TimeUnit import javax.inject.Inject -import kotlin.math.log private const val TAG = "ConfigToDatabaseSync" @@ -81,6 +78,7 @@ class ConfigToDatabaseSync @Inject constructor( private val groupMemberDatabase: GroupMemberDatabase, private val communityDatabase: CommunityDatabase, private val lokiAPIDatabase: LokiAPIDatabase, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, private val clock: SnodeClock, private val preferences: TextSecurePreferences, private val conversationRepository: ConversationRepository, @@ -89,27 +87,22 @@ class ConfigToDatabaseSync @Inject constructor( private val messageNotifier: MessageNotifier, private val recipientSettingsDatabase: RecipientSettingsDatabase, private val avatarCacheCleaner: AvatarCacheCleaner, + private val loginStateRepository: LoginStateRepository, @param:ManagerScope private val scope: CoroutineScope, ) : OnAppStartupComponent { init { // Sync conversations from config -> database scope.launch { - preferences.watchLocalNumber() - .map { it != null } - .flatMapLatest { loggedIn -> - if (loggedIn) { - combine( - conversationRepository.conversationListAddressesFlow, - configFactory.userConfigsChanged(EnumSet.of(UserConfigType.CONVO_INFO_VOLATILE)) - .castAwayType() - .onStart { emit(Unit) } - .map { _ -> configFactory.withUserConfigs { it.convoInfoVolatile.all() } }, - ::Pair - ) - } else { - emptyFlow() - } - } + loginStateRepository.flowWithLoggedInState { + combine( + conversationRepository.conversationListAddressesFlow, + configFactory.userConfigsChanged(EnumSet.of(UserConfigType.CONVO_INFO_VOLATILE)) + .castAwayType() + .onStart { emit(Unit) } + .map { _ -> configFactory.withUserConfigs { it.convoInfoVolatile.all() } }, + ::Pair + ) + } .distinctUntilChanged() .collectLatest { (conversations, convoInfo) -> try { @@ -195,7 +188,7 @@ class ConfigToDatabaseSync @Inject constructor( private fun deleteGroupData(address: Address.Group) { lokiAPIDatabase.clearLastMessageHashes(address.accountId.hexString) - lokiAPIDatabase.clearReceivedMessageHashValues(address.accountId.hexString) + receivedMessageHashDatabase.removeAllByPublicKey(address.accountId.hexString) } private fun onLegacyGroupAdded( @@ -216,7 +209,7 @@ class ConfigToDatabaseSync @Inject constructor( val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey.data), DjbECPrivateKey(group.encSecKey.data)) storage.addClosedGroupEncryptionKeyPair(keyPair, group.accountId, clock.currentTimeMills()) // Notify the PN server - PushRegistryV1.subscribeGroup(group.accountId, publicKey = preferences.getLocalNumber()!!) + PushRegistryV1.subscribeGroup(group.accountId, publicKey = loginStateRepository.requireLocalNumber()) threadDatabase.setCreationDate(threadId, formationTimestamp) } @@ -260,7 +253,7 @@ class ConfigToDatabaseSync @Inject constructor( } private fun deleteLegacyGroupData(address: Address.LegacyGroup) { - val myAddress = preferences.getLocalNumber()!! + val myAddress = loginStateRepository.requireLocalNumber() // Mark the group as inactive storage.setActive(address.address, false) diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt index d7300a1e4d..9ab0adfbae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt @@ -45,6 +45,7 @@ import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.retryWithUniformInterval +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.util.NetworkConnectivity import javax.inject.Inject @@ -67,7 +68,7 @@ class ConfigUploader @Inject constructor( private val storageProtocol: StorageProtocol, private val clock: SnodeClock, private val networkConnectivity: NetworkConnectivity, - private val textSecurePreferences: TextSecurePreferences, + private val loginStateRepository: LoginStateRepository, ) : OnAppStartupComponent { private var job: Job? = null @@ -89,7 +90,7 @@ class ConfigUploader @Inject constructor( } // A flow that emits true when there's a logged in user - private fun hasLoggedInUser(): Flow = textSecurePreferences.watchLocalNumber() + private fun hasLoggedInUser(): Flow = loginStateRepository.loggedInState .map { it != null } .distinctUntilChanged() @@ -250,7 +251,7 @@ class ConfigUploader @Inject constructor( ), auth ), - responseType = StoreMessageResponse::class.java + responseType = StoreMessageResponse.serializer() ).let(::listOf).toConfigPushResult() } @@ -284,7 +285,7 @@ class ConfigUploader @Inject constructor( val pendingConfig = configs.groupKeys.pendingConfig() if (pendingConfig != null) { for (hash in hashes) { - configs.groupKeys.loadKey(pendingConfig, hash, timestamp) + configs.groupKeys.loadKey(pendingConfig, hash, timestamp.toEpochMilli()) } } } @@ -329,7 +330,7 @@ class ConfigUploader @Inject constructor( ), auth, ), - responseType = StoreMessageResponse::class.java + responseType = StoreMessageResponse.serializer() ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt index ac4257cd92..ffdea7cb52 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt @@ -18,17 +18,19 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.isGroupV2 import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.ui.getSubbedCharSequence import javax.inject.Inject class DisappearingMessages @Inject constructor( - private val textSecurePreferences: TextSecurePreferences, private val messageExpirationManager: MessageExpirationManagerProtocol, private val storage: StorageProtocol, private val groupManagerV2: GroupManagerV2, private val clock: SnodeClock, + private val messageSender: MessageSender, + private val loginStateRepository: LoginStateRepository, ) { fun set(address: Address, mode: ExpiryMode, isGroup: Boolean) { storage.setExpirationConfiguration(address, mode) @@ -38,14 +40,14 @@ class DisappearingMessages @Inject constructor( } else { val message = ExpirationTimerUpdate(isGroup = isGroup).apply { expiryMode = mode - sender = textSecurePreferences.getLocalNumber() + sender = loginStateRepository.getLocalNumber() isSenderSelf = true recipient = address.toString() sentTimestamp = clock.currentTimeMills() } messageExpirationManager.insertExpirationTimerMessage(message) - MessageSender.send(message, address) + messageSender.send(message, address) } } 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 deleted file mode 100644 index 85dde6ae7e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.thoughtcrime.securesms.conversation.disappearingmessages - -import androidx.compose.runtime.Composable -import androidx.core.content.IntentCompat -import androidx.hilt.navigation.compose.hiltViewModel -import dagger.hilt.android.AndroidEntryPoint -import network.loki.messenger.BuildConfig -import org.session.libsession.messaging.messages.ExpirationConfiguration -import org.session.libsession.utilities.Address -import org.thoughtcrime.securesms.FullComposeScreenLockActivity -import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.DisappearingMessagesScreen - -@AndroidEntryPoint -class DisappearingMessagesActivity: FullComposeScreenLockActivity() { - - @Composable - override fun ComposeContent() { - val viewModel: DisappearingMessagesViewModel = - hiltViewModel { factory -> - factory.create( - address = requireNotNull( - IntentCompat.getParcelableExtra(intent, ARG_ADDRESS, Address::class.java) - ) { - "DisappearingMessagesActivity requires an Address to be passed in via the intent." - }, - isNewConfigEnabled = ExpirationConfiguration.isNewConfigEnabled, - showDebugOptions = BuildConfig.BUILD_TYPE != "release" - ) - } - - DisappearingMessagesScreen( - viewModel = viewModel, - onBack = { finish() }, - ) - } - - companion object { - const val ARG_ADDRESS = "address" - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt index 1ba68f20a6..cb9a40ca23 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt @@ -31,9 +31,9 @@ class DisappearingMessagesViewModel @AssistedInject constructor( @Assisted private val address: Address, @Assisted("isNewConfigEnabled") private val isNewConfigEnabled: Boolean, @Assisted("showDebugOptions") private val showDebugOptions: Boolean, + @Assisted private val navigator: UINavigator, @param:ApplicationContext private val context: Context, private val disappearingMessages: DisappearingMessages, - private val navigator: UINavigator, private val recipientRepository: RecipientRepository, ) : ViewModel() { @@ -96,7 +96,8 @@ class DisappearingMessagesViewModel @AssistedInject constructor( fun create( address: Address, @Assisted("isNewConfigEnabled") isNewConfigEnabled: Boolean, - @Assisted("showDebugOptions") showDebugOptions: Boolean + @Assisted("showDebugOptions") showDebugOptions: Boolean, + navigator: UINavigator ): DisappearingMessagesViewModel } } 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 8440cb6562..fa27f388e5 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 @@ -19,7 +19,6 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.os.SystemClock -import android.provider.MediaStore import android.provider.Settings import android.text.Spannable import android.text.SpannableStringBuilder @@ -59,7 +58,6 @@ import androidx.recyclerview.widget.RecyclerView import com.annimon.stream.Stream import com.bumptech.glide.Glide import com.squareup.phrase.Phrase -import dagger.Lazy import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.withCreationCallback import kotlinx.coroutines.CancellationException @@ -97,6 +95,7 @@ import org.session.libsession.messaging.open_groups.OpenGroupApi 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.notifications.MessageNotifier import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeClock @@ -118,16 +117,14 @@ import org.session.libsession.utilities.recipients.displayName import org.session.libsignal.crypto.MnemonicCodec import org.session.libsignal.utilities.ListenableFuture import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.hexEncodedPrivateKey -import org.thoughtcrime.securesms.ApplicationContext +import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.FullComposeActivity.Companion.applyCommonPropertiesForCompose import org.thoughtcrime.securesms.ScreenLockActionBarActivity -import org.thoughtcrime.securesms.attachments.ScreenshotObserver import org.thoughtcrime.securesms.audio.AudioRecorderHandle import org.thoughtcrime.securesms.audio.recordAudio +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.components.TypingStatusSender import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel -import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.ShowOpenUrlDialog @@ -136,7 +133,6 @@ import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companio import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_REPLY import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_RESEND import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_SAVE -import org.thoughtcrime.securesms.conversation.v2.dialogs.BlockedDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate @@ -153,13 +149,12 @@ import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDel import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsActivity +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsActivity import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities -import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.crypto.MnemonicUtilities -import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.MmsDatabase @@ -265,22 +260,21 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, @Inject lateinit var typingStatusSender: TypingStatusSender @Inject lateinit var openGroupManager: OpenGroupManager @Inject lateinit var clock: SnodeClock + @Inject lateinit var messageSender: MessageSender + @Inject lateinit var resendMessageUtilities: ResendMessageUtilities + @Inject lateinit var messageNotifier: MessageNotifier @Inject @ManagerScope lateinit var scope: CoroutineScope + @Inject + lateinit var loginStateRepository: LoginStateRepository + override val applyDefaultWindowInsets: Boolean get() = false override val applyAutoScrimForNavigationBar: Boolean get() = false - private val screenshotObserver by lazy { - ScreenshotObserver(this, Handler(Looper.getMainLooper())) { - // post screenshot message - sendScreenshotNotification() - } - } - private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private val linkPreviewViewModel: LinkPreviewViewModel by lazy { ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository())) @@ -288,9 +282,22 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } private val address: Address.Conversable by lazy { - requireNotNull(IntentCompat.getParcelableExtra(intent, ADDRESS, Address.Conversable::class.java)) { - "Address must be provided in the intent extras to open a conversation" + val fromExtras = + IntentCompat.getParcelableExtra(intent, ADDRESS, Address.Conversable::class.java) + if (fromExtras != null) { + return@lazy fromExtras + } + + // Fallback: parse from URI + val serialized = intent.data?.getQueryParameter(ADDRESS) + if (!serialized.isNullOrEmpty()) { + val parsed = fromSerialized(serialized) + if (parsed is Address.Conversable) { + return@lazy parsed + } } + + throw IllegalArgumentException("Address must be provided in the intent extras or URI") } private val viewModel: ConversationViewModel by viewModels(extrasProducer = { @@ -348,16 +355,17 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, get() { return binding.conversationRecyclerView.layoutManager as LinearLayoutManager? } private val seed by lazy { - var hexEncodedSeed = IdentityKeyUtil.retrieve(this, IdentityKeyUtil.LOKI_SEED) - if (hexEncodedSeed == null) { - hexEncodedSeed = IdentityKeyUtil.getIdentityKeyPair(this).hexEncodedPrivateKey // Legacy account + val seedHex = loginStateRepository.peekLoginState()?.seeded?.seed?.data?.toHexString() + + if (seedHex.isNullOrBlank()) { + return@lazy "" } val appContext = applicationContext val loadFileContents: (String) -> String = { fileName -> MnemonicUtilities.loadFileContents(appContext, fileName) } - MnemonicCodec(loadFileContents).encode(hexEncodedSeed, MnemonicCodec.Language.Configuration.english) + MnemonicCodec(loadFileContents).encode(seedHex, MnemonicCodec.Language.Configuration.english) } private val lastCursorLoaded = MutableSharedFlow(extraBufferCapacity = 1) @@ -393,6 +401,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, retryFailedAttachments = viewModel::retryFailedAttachments, glide = glide, threadRecipientProvider = viewModel::recipient, + messageDB = mmsSmsDb, ) adapter.visibleMessageViewDelegate = this adapter @@ -533,7 +542,14 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, super.onCreate(savedInstanceState, isReady) // Check if address is null before proceeding with initialization - if (IntentCompat.getParcelableExtra(intent, ADDRESS, Address.Conversable::class.java) == null) { + if ( + IntentCompat.getParcelableExtra( + intent, + ADDRESS, + Address.Conversable::class.java + ) == null && + intent.data?.getQueryParameter(ADDRESS).isNullOrEmpty() + ) { Log.w(TAG, "ConversationActivityV2 launched without ADDRESS extra - Returning home") val intent = Intent(this, HomeActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK @@ -696,9 +712,11 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } is ConversationUiEvent.ShowDisappearingMessages -> { - val intent = Intent(this@ConversationActivityV2, DisappearingMessagesActivity::class.java).apply { - putExtra(DisappearingMessagesActivity.ARG_ADDRESS, event.address) - } + val intent = ConversationSettingsActivity.createIntent( + context = this@ConversationActivityV2, + address = event.address, + startDestination = ConversationSettingsDestination.RouteDisappearingMessages + ) startActivity(intent) } @@ -751,19 +769,12 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, override fun onResume() { super.onResume() - ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(viewModel.threadId) - - contentResolver.registerContentObserver( - MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - true, - screenshotObserver - ) + messageNotifier.setVisibleThread(viewModel.threadId) } override fun onPause() { super.onPause() - ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(-1) - contentResolver.unregisterContentObserver(screenshotObserver) + messageNotifier.setVisibleThread(-1) } override fun getSystemService(name: String): Any? { @@ -779,7 +790,12 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } override fun onCreateLoader(id: Int, bundle: Bundle?): Loader { - return ConversationLoader(viewModel.threadId, false, this@ConversationActivityV2) + return ConversationLoader( + threadID = viewModel.threadId, + reverse = false, + context = this@ConversationActivityV2, + mmsSmsDatabase = mmsSmsDb + ) } override fun onLoadFinished(loader: Loader, cursor: Cursor?) { @@ -1692,7 +1708,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, override fun onReactionSelected(messageRecord: MessageRecord, emoji: String) { reactionDelegate.hide() - val oldRecord = messageRecord.reactions.find { it.author == textSecurePreferences.getLocalNumber() } + val oldRecord = messageRecord.reactions.find { it.author == loginStateRepository.getLocalNumber() } if (oldRecord != null && oldRecord.emoji == emoji) { sendEmojiRemoval(emoji, messageRecord) } else { @@ -1744,32 +1760,48 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, val reactionMessage = VisibleMessage() val emojiTimestamp = SnodeAPI.nowWithOffset reactionMessage.sentTimestamp = emojiTimestamp - val author = textSecurePreferences.getLocalNumber() + val author = loginStateRepository.getLocalNumber() if (author == null) { Log.w(TAG, "Unable to locate local number when sending emoji reaction - aborting.") return } else { // Put the message in the database + val messageId = originalMessage.messageId + val count = if (recipient.isCommunityRecipient) { + // ReactionRecord count is total number of reactions for this emoji/messageId in community, + // regardless of the author of each ReactionRecord. + // We set to the existing number for now and we'll increase all of them + // by 1 below. + originalMessage.reactions.firstOrNull { it.messageId == messageId && it.emoji == emoji } + ?.count ?: 0 + } else { + 1 + } + val reaction = ReactionRecord( - messageId = originalMessage.messageId, + messageId = messageId, author = author, emoji = emoji, - count = 1, + count = count, dateSent = emojiTimestamp, dateReceived = emojiTimestamp ) reactionDb.addReaction(reaction) val originalAuthor = if (originalMessage.isOutgoing) { - fromSerialized(viewModel.blindedPublicKey ?: textSecurePreferences.getLocalNumber()!!) + fromSerialized(viewModel.blindedPublicKey ?: loginStateRepository.requireLocalNumber()) } else originalMessage.individualRecipient.address // Send it reactionMessage.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.toString(), emoji, true) if (recipient.address is Address.Community) { - val messageServerId = lokiMessageDb.getServerID(originalMessage.messageId) ?: + // Increment the reaction count locally immediately. This + // has to apply on all the ReactionRecords with the same messageId/emoji per design. + reactionDb.updateAllCountFor(messageId, emoji, 1) + + val messageServerId = lokiMessageDb.getServerID(messageId) ?: return Log.w(TAG, "Failed to find message server ID when adding emoji reaction") scope.launch { @@ -1783,7 +1815,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } } else { - MessageSender.send(reactionMessage, recipient.address) + messageSender.send(reactionMessage, recipient.address) } LoaderManager.getInstance(this).restartLoader(0, null, this) @@ -1797,7 +1829,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, val message = VisibleMessage() val emojiTimestamp = SnodeAPI.nowWithOffset message.sentTimestamp = emojiTimestamp - val author = textSecurePreferences.getLocalNumber() + val author = loginStateRepository.getLocalNumber() if (author == null) { Log.w(TAG, "Unable to locate local number when removing emoji reaction - aborting.") @@ -1805,7 +1837,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } else { reactionDb.deleteReaction( emoji, - MessageId(originalMessage.id, originalMessage.isMms), + originalMessage.messageId, author ) @@ -1815,12 +1847,16 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, message.reaction = Reaction.from( timestamp = originalMessage.timestamp, - author = originalAuthor.address, + author = originalAuthor.address, emoji = emoji, react = false ) if (recipient.address is Address.Community) { + // Decrement the reaction count locally immediately (they will + // get overwritten when we get the server update back) + reactionDb.updateAllCountFor(originalMessage.messageId, emoji, -1) + val messageServerId = lokiMessageDb.getServerID(originalMessage.messageId) ?: return Log.w(TAG, "Failed to find message server ID when removing emoji reaction") @@ -1836,14 +1872,14 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } } else { - MessageSender.send(message, recipient.address) + messageSender.send(message, recipient.address) } LoaderManager.getInstance(this).restartLoader(0, null, this) } } override fun onCustomReactionSelected(messageRecord: MessageRecord, hasAddedCustomEmoji: Boolean) { - val oldRecord = messageRecord.reactions.find { record -> record.author == textSecurePreferences.getLocalNumber() } + val oldRecord = messageRecord.reactions.find { record -> record.author == loginStateRepository.getLocalNumber() } if (oldRecord != null && hasAddedCustomEmoji) { reactionDelegate.hide() @@ -1861,12 +1897,8 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, override fun onReactWithAnyEmojiSelected(emoji: String, messageId: MessageId) { reactionDelegate.hide() - val message = if (messageId.mms) { - mmsDb.getMessageRecord(messageId.id) - } else { - smsDb.getMessageRecord(messageId.id) - } - val oldRecord = reactionDb.getReactions(messageId).find { it.author == textSecurePreferences.getLocalNumber() } + val message = mmsSmsDb.getMessageById(messageId) ?: return + val oldRecord = reactionDb.getReactions(messageId).find { it.author == loginStateRepository.getLocalNumber() } if (oldRecord?.emoji == emoji) { sendEmojiRemoval(emoji, message) } else { @@ -1875,11 +1907,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } override fun onRemoveReaction(emoji: String, messageId: MessageId) { - val message = if (messageId.mms) { - mmsDb.getMessageRecord(messageId.id) - } else { - smsDb.getMessageRecord(messageId.id) - } + val message = mmsSmsDb.getMessageById(messageId) ?: return sendEmojiRemoval(emoji, message) } @@ -2002,11 +2030,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } override fun onReactionClicked(emoji: String, messageId: MessageId, userWasSender: Boolean) { - val message = if (messageId.mms) { - mmsDb.getMessageRecord(messageId.id) - } else { - smsDb.getMessageRecord(messageId.id) - } + val message = mmsSmsDb.getMessageById(messageId) ?: return if (userWasSender && viewModel.canRemoveReaction) { sendEmojiRemoval(emoji, message) } else if (!userWasSender && viewModel.canReactToMessages) { @@ -2017,7 +2041,13 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, override fun onReactionLongClicked(messageId: MessageId, emoji: String?) { if (viewModel.recipient.isGroupOrCommunityRecipient) { val isUserCommunityModerator = viewModel.recipient.takeIf { it.isCommunityRecipient }?.currentUserRole?.canModerate == true - val fragment = ReactionsDialogFragment.create(messageId, isUserCommunityModerator, emoji, viewModel.canRemoveReaction) + val fragment = ReactionsDialogFragment.create( + messageId, + isUserCommunityModerator, + emoji, + viewModel.canRemoveReaction, + viewModel.recipient.isCommunityRecipient + ) fragment.show(supportFragmentManager, TAG_REACTION_FRAGMENT) } } @@ -2042,9 +2072,11 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, override fun sendMessage() { val recipient = viewModel.recipient + // It shouldn't be possible to send a message to a blocked user anymore. + // But as a safety net: // show the unblock dialog when trying to send a message to a blocked contact if (recipient.isStandardRecipient && recipient.blocked) { - BlockedDialog(recipient.address, recipient.displayName()).show(supportFragmentManager, "Blocked Dialog") + unblock() return } @@ -2113,7 +2145,12 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, message.sentTimestamp = sentTimestamp message.text = text val expiresInMillis = viewModel.recipient.expiryMode.expiryMillis - val outgoingTextMessage = OutgoingTextMessage.from(message, recipient.address, expiresInMillis, 0) + val outgoingTextMessage = OutgoingTextMessage( + message = message, + recipient = recipient.address, + expiresInMillis = expiresInMillis, + expireStartedAtMillis = 0 + ) // Clear the input bar binding.inputBar.text = "" @@ -2130,7 +2167,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, ), false) waitForApprovalJobToBeSubmitted() - MessageSender.send(message, recipient.address) + messageSender.send(message, recipient.address) } // Send a typing stopped message typingStatusSender.onTypingStopped(viewModel.threadId) @@ -2155,13 +2192,13 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, val quote = quotedMessage?.let { val quotedAttachments = (it as? MmsMessageRecord)?.slideDeck?.asAttachments() ?: listOf() val sender = if (it.isOutgoing) { - fromSerialized(viewModel.blindedPublicKey ?: textSecurePreferences.getLocalNumber()!!) + fromSerialized(viewModel.blindedPublicKey ?: loginStateRepository.requireLocalNumber()) } else it.individualRecipient.address QuoteModel(it.dateSent, sender, it.body, false, quotedAttachments) } val localQuote = quotedMessage?.let { val sender = - if (it.isOutgoing) fromSerialized(textSecurePreferences.getLocalNumber()!!) + if (it.isOutgoing) fromSerialized(loginStateRepository.requireLocalNumber()) else it.individualRecipient.address quote?.copy(author = sender) } @@ -2169,7 +2206,15 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, val expireStartedAtMs = if (viewModel.recipient.expiryMode is ExpiryMode.AfterSend) { sentTimestamp } else 0 - val outgoingTextMessage = OutgoingMediaMessage.from(message, recipient.address, attachments, localQuote, linkPreview, expiresInMs, expireStartedAtMs) + val outgoingTextMessage = OutgoingMediaMessage( + message = message, + recipient = recipient.address, + attachments = attachments, + outgoingQuote = localQuote, + linkPreview = linkPreview, + expiresInMillis = expiresInMs, + expireStartedAt = expireStartedAtMs + ) // Clear the input bar binding.inputBar.text = "" @@ -2208,7 +2253,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, waitForApprovalJobToBeSubmitted() - MessageSender.send(message, recipient.address, quote, linkPreview) + messageSender.send(message, recipient.address, quote, linkPreview) }.onFailure { withContext(Dispatchers.Main){ when (it) { @@ -2547,11 +2592,11 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } override fun resyncMessage(messages: Set) { - val accountId = textSecurePreferences.getLocalNumber() + val accountId = loginStateRepository.getLocalNumber() scope.launch { messages.iterator().forEach { messageRecord -> runCatching { - ResendMessageUtilities.resend( + resendMessageUtilities.resend( accountId, messageRecord, viewModel.blindedPublicKey, @@ -2565,11 +2610,11 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } override fun resendMessage(messages: Set) { - val accountId = textSecurePreferences.getLocalNumber() + val accountId = loginStateRepository.getLocalNumber() scope.launch { messages.iterator().forEach { messageRecord -> runCatching { - ResendMessageUtilities.resend( + resendMessageUtilities.resend( accountId, messageRecord, viewModel.blindedPublicKey @@ -2718,21 +2763,13 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, this.actionMode = null } - private fun sendScreenshotNotification() { - val recipient = viewModel.recipient - if (recipient.isGroupOrCommunityRecipient) return - val kind = DataExtractionNotification.Kind.Screenshot() - val message = DataExtractionNotification(kind) - MessageSender.send(message, recipient.address) - } - private fun sendMediaSavedNotification() { val recipient = viewModel.recipient if (recipient.isGroupOrCommunityRecipient) { return } val timestamp = SnodeAPI.nowWithOffset val kind = DataExtractionNotification.Kind.MediaSaved(timestamp) val message = DataExtractionNotification(kind) - MessageSender.send(message, recipient.address) + messageSender.send(message, recipient.address) } private fun endActionMode() { 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 21efaf7767..38d4b23236 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 @@ -17,7 +17,6 @@ import org.thoughtcrime.securesms.database.MmsSmsColumns import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord -import org.thoughtcrime.securesms.dependencies.DatabaseComponent import java.util.concurrent.atomic.AtomicLong import kotlin.math.min @@ -33,8 +32,8 @@ class ConversationAdapter( private val retryFailedAttachments: (List) -> Unit, private val glide: RequestManager, private val threadRecipientProvider: () -> Recipient, + val messageDB: MmsSmsDatabase, ) : CursorRecyclerViewAdapter(context) { - private val messageDB by lazy { DatabaseComponent.get(context).mmsSmsDatabase() } var selectedItems = mutableSetOf() var isAdmin: Boolean = false private var searchQuery: String? = null diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt index 4692bf7862..c85daf6e23 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt @@ -2,16 +2,17 @@ package org.thoughtcrime.securesms.conversation.v2 import android.content.Context import android.database.Cursor -import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.util.AbstractCursorLoader class ConversationLoader( private val threadID: Long, private val reverse: Boolean, - context: Context + context: Context, + val mmsSmsDatabase: MmsSmsDatabase ) : AbstractCursorLoader(context) { override fun getCursor(): Cursor { - return DatabaseComponent.get(context).mmsSmsDatabase().getConversation(threadID, reverse) + return mmsSmsDatabase.getConversation(threadID, reverse) } } \ No newline at end of file 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 8663c8fa0b..5b483b41a2 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 @@ -40,8 +40,6 @@ import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.isCommunity @@ -106,7 +104,6 @@ class ConversationReactionOverlay : FrameLayout { @Inject lateinit var repository: ConversationRepository @Inject lateinit var dateUtils: DateUtils @Inject lateinit var threadDatabase: ThreadDatabase - @Inject lateinit var textSecurePreferences: TextSecurePreferences @Inject lateinit var deprecationManager: LegacyGroupDeprecationManager @Inject lateinit var openGroupManager: OpenGroupManager @@ -581,12 +578,6 @@ class ConversationReactionOverlay : FrameLayout { this.onHideListener = onHideListener } - private fun getOldEmoji(messageRecord: MessageRecord): String? = - messageRecord.reactions - .filter { it.author == getLocalNumber(context) } - .firstOrNull() - ?.let(ReactionRecord::emoji) - private fun getMenuActionItems(message: MessageRecord, recipient: Address): List { val items: MutableList = ArrayList() 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 5a87cc3cef..f44375d22c 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 @@ -44,8 +44,9 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.PRIORITY_HIDDEN +import network.loki.messenger.libsession_util.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.util.BitSet import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.BlindedContact import network.loki.messenger.libsession_util.util.ExpiryMode @@ -73,7 +74,6 @@ import org.session.libsession.utilities.recipients.displayName import org.session.libsession.utilities.recipients.effectiveNotifyType import org.session.libsession.utilities.recipients.getType import org.session.libsession.utilities.recipients.repeatedWithEffectiveNotifyTypeChange -import org.session.libsession.utilities.recipients.shouldShowProBadge import org.session.libsession.utilities.toGroupString import org.session.libsession.utilities.upsertContact import org.session.libsession.utilities.userConfigsChanged @@ -526,7 +526,7 @@ class ConversationViewModel @AssistedInject constructor( application.resources.getQuantityString(R.plurals.membersActive, userCount, userCount) } else { val userCount = if (conversation.data is RecipientData.Group) { - conversation.data.partial.members.size + conversation.data.members.size } else { // legacy closed groups groupDb.getGroupMemberAddresses(conversation.address.toGroupString(), true).size } @@ -553,7 +553,7 @@ class ConversationViewModel @AssistedInject constructor( showSearch = showSearch, avatarUIData = avatarData, // show the pro badge when a conversation/user is pro, except for communities - showProBadge = conversation.proStatus.shouldShowProBadge() && !conversation.isLocalNumber // do not show for note to self + showProBadge = conversation.shouldShowProBadge && !conversation.isLocalNumber // do not show for note to self ).also { // also preload the larger version of the avatar in case the user goes to the settings avatarData.elements.mapNotNull { it.remoteFile }.forEach { @@ -647,7 +647,7 @@ class ConversationViewModel @AssistedInject constructor( // this would be a request from us instead. ( (recipient.data is RecipientData.Contact && !recipient.data.approved) || - (recipient.data is RecipientData.Group && !recipient.data.partial.approved) + (recipient.data is RecipientData.Group && !recipient.data.approved) ) && // Req 2: the type of conversation supports message request @@ -1297,7 +1297,7 @@ class ConversationViewModel @AssistedInject constructor( } } - _uiEvents.tryEmit(ConversationUiEvent.ShowDisappearingMessages(convo.address)) + _uiEvents.tryEmit(ConversationUiEvent.ShowDisappearingMessages(address)) } } @@ -1382,7 +1382,8 @@ class ConversationViewModel @AssistedInject constructor( createdEpochSeconds = ZonedDateTime.now().toEpochSecond(), profilePic = recipient.data.avatar?.toUserPic() ?: UserPic.DEFAULT, profileUpdatedEpochSeconds = recipient.data.profileUpdatedAt?.toEpochSeconds() ?: 0L, - priority = PRIORITY_VISIBLE + priority = PRIORITY_VISIBLE, + proFeatures = BitSet(), ) ) } @@ -1459,7 +1460,7 @@ data class UiMessage(val id: Long, val message: String) sealed interface ConversationUiEvent { data class NavigateToConversation(val address: Address.Conversable) : ConversationUiEvent - data class ShowDisappearingMessages(val address: Address) : ConversationUiEvent + data class ShowDisappearingMessages(val address: Address.Conversable) : ConversationUiEvent data class ShowNotificationSettings(val address: Address) : ConversationUiEvent data class ShowGroupMembers(val groupAddress: Address.Group) : ConversationUiEvent data class ShowConversationSettings(val threadAddress: Address.Conversable) : ConversationUiEvent 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 a94a63388d..f49a5f79c6 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 @@ -65,6 +65,9 @@ import dagger.hilt.android.lifecycle.withCreationCallback import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageContentBinding +import network.loki.messenger.libsession_util.protocol.ProFeature +import network.loki.messenger.libsession_util.protocol.ProMessageFeature +import network.loki.messenger.libsession_util.protocol.ProProfileFeature import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.utilities.NonTranslatableStringConstants @@ -310,7 +313,7 @@ fun CellMetadata( verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) ) { // Message Pro features - if(proFeatures.isNotEmpty()) { + if (!proFeatures.isEmpty()) { MessageProFeatures( features = proFeatures, badgeClickable = proBadgeClickable, @@ -372,7 +375,7 @@ fun CellMetadata( @Composable fun MessageProFeatures( - features: Set, + features: Set, badgeClickable: Boolean, sendCommand: (Commands) -> Unit, modifier: Modifier = Modifier, @@ -397,18 +400,18 @@ fun MessageProFeatures( style = LocalType.current.large ) - features.forEach { + features.forEach { feature -> ProCTAFeature( textStyle = LocalType.current.large, padding = PaddingValues(), data = CTAFeature.Icon( - text = when(it){ - ProStatusManager.MessageProFeature.ProBadge -> Phrase.from(LocalContext.current, R.string.appProBadge) + text = when (feature){ + ProProfileFeature.PRO_BADGE -> Phrase.from(LocalContext.current, R.string.appProBadge) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format() .toString() - ProStatusManager.MessageProFeature.LongMessage -> stringResource(id = R.string.proIncreasedMessageLengthFeature) - ProStatusManager.MessageProFeature.AnimatedAvatar -> stringResource(id = R.string.proAnimatedDisplayPictureFeature) + ProMessageFeature.HIGHER_CHARACTER_LIMIT -> stringResource(id = R.string.proIncreasedMessageLengthFeature) + ProProfileFeature.ANIMATED_AVATAR -> stringResource(id = R.string.proAnimatedDisplayPictureFeature) } ) ) @@ -421,11 +424,7 @@ fun MessageProFeatures( fun PreviewMessageProFeatures(){ PreviewTheme { MessageProFeatures( - features = setOf( - ProStatusManager.MessageProFeature.ProBadge, - ProStatusManager.MessageProFeature.LongMessage, - ProStatusManager.MessageProFeature.AnimatedAvatar - ), + features = (ProMessageFeature.entries + ProProfileFeature.entries).toSet(), badgeClickable = false, sendCommand = {} ) @@ -706,13 +705,22 @@ fun MessageDetailDialogs( if(state.proBadgeCTA != null){ when(state.proBadgeCTA){ is ProBadgeCTA.Generic -> - GenericProCTA(onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)}) + GenericProCTA( + proSubscription = state.proBadgeCTA.proSubscription, + onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)} + ) is ProBadgeCTA.LongMessage -> - LongMessageProCTA(onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)}) + LongMessageProCTA( + proSubscription = state.proBadgeCTA.proSubscription, + onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)} + ) is ProBadgeCTA.AnimatedProfile -> - AnimatedProfilePicProCTA(onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)}) + AnimatedProfilePicProCTA( + proSubscription = state.proBadgeCTA.proSubscription, + onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)} + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt index 9319d0049a..31ec45ffbf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt @@ -25,16 +25,17 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R +import network.loki.messenger.libsession_util.protocol.ProFeature +import network.loki.messenger.libsession_util.protocol.ProMessageFeature +import network.loki.messenger.libsession_util.protocol.ProProfileFeature +import network.loki.messenger.libsession_util.util.asSequence import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.utilities.Address -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.isLegacyGroup import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientData import org.session.libsession.utilities.recipients.displayName -import org.session.libsession.utilities.recipients.isPro -import org.session.libsession.utilities.recipients.shouldShowProBadge import org.session.libsignal.utilities.IdPrefix import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.MediaPreviewArgs @@ -48,9 +49,8 @@ import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.mms.ImageSlide import org.thoughtcrime.securesms.mms.Slide +import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.pro.ProStatusManager -import org.thoughtcrime.securesms.pro.ProStatusManager.MessageProFeature.AnimatedAvatar -import org.thoughtcrime.securesms.pro.ProStatusManager.MessageProFeature.LongMessage import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.TitledText import org.thoughtcrime.securesms.util.AvatarUIData @@ -66,7 +66,6 @@ import kotlin.text.Typography.ellipsis @HiltViewModel(assistedFactory = MessageDetailsViewModel.Factory::class) class MessageDetailsViewModel @AssistedInject constructor( @Assisted val messageId: MessageId, - private val prefs: TextSecurePreferences, private val attachmentDb: AttachmentDatabase, private val lokiMessageDatabase: LokiMessageDatabase, private val mmsSmsDatabase: MmsSmsDatabase, @@ -148,7 +147,7 @@ class MessageDetailsViewModel @AssistedInject constructor( } val sender = if(messageRecord.isOutgoing){ - recipientRepository.getRecipient(Address.fromSerialized(prefs.getLocalNumber()!!)) + recipientRepository.getSelf() } else individualRecipient val attachments = slides.map(::Attachment) @@ -199,13 +198,13 @@ class MessageDetailsViewModel @AssistedInject constructor( ) }, senderAvatarData = avatarUtils.getUIDataFromRecipient(sender), - senderShowProBadge = sender.proStatus.shouldShowProBadge(), + senderShowProBadge = sender.shouldShowProBadge, senderHasAdminCrown = shouldShowAdminCrown, senderIsBlinded = IdPrefix.fromValue(sender.address.toString())?.isBlinded() ?: false, thread = conversation, readOnly = isDeprecatedLegacyGroup, - proFeatures = proStatusManager.getMessageProFeatures(messageRecord.messageId), - proBadgeClickable = !recipientRepository.getSelf().proStatus.isPro() // no badge click if the current user is pro + proFeatures = proStatusManager.getMessageProFeatures(messageRecord), + proBadgeClickable = !recipientRepository.getSelf().isPro // no badge click if the current user is pro ) } } @@ -283,13 +282,14 @@ class MessageDetailsViewModel @AssistedInject constructor( is Commands.ShowProBadgeCTA -> { val features = state.value.proFeatures _dialogState.update { + val proSubscription = proStatusManager.proDataState.value.type it.copy( proBadgeCTA = when{ - features.size > 1 -> ProBadgeCTA.Generic // always show the generic cta when there are more than 1 feature + features.size > 1 -> ProBadgeCTA.Generic(proSubscription) // always show the generic cta when there are more than 1 feature - features.contains(LongMessage) -> ProBadgeCTA.LongMessage - features.contains(AnimatedAvatar) -> ProBadgeCTA.AnimatedProfile - else -> ProBadgeCTA.Generic + features.contains(ProMessageFeature.HIGHER_CHARACTER_LIMIT) -> ProBadgeCTA.LongMessage(proSubscription) + features.contains(ProProfileFeature.ANIMATED_AVATAR) -> ProBadgeCTA.AnimatedProfile(proSubscription) + else -> ProBadgeCTA.Generic(proSubscription) } ) } @@ -352,7 +352,7 @@ data class MessageDetailsState( val senderIsBlinded: Boolean = false, val thread: Recipient? = null, val readOnly: Boolean = false, - val proFeatures: Set = emptySet(), + val proFeatures: Set = emptySet(), val proBadgeClickable: Boolean = false, ) { val fromTitle = GetString(R.string.from) @@ -369,10 +369,10 @@ data class MessageDetailsState( val canDelete: Boolean get() = !readOnly } -sealed interface ProBadgeCTA { - data object Generic: ProBadgeCTA - data object LongMessage: ProBadgeCTA - data object AnimatedProfile: ProBadgeCTA +sealed class ProBadgeCTA(open val proSubscription: ProStatus) { + data class Generic(override val proSubscription: ProStatus): ProBadgeCTA(proSubscription) + data class LongMessage(override val proSubscription: ProStatus): ProBadgeCTA(proSubscription) + data class AnimatedProfile(override val proSubscription: ProStatus): ProBadgeCTA(proSubscription) } data class DialogsState( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt index f9a10c602d..e362531fbb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt @@ -19,7 +19,10 @@ class TypingIndicatorViewContainer : LinearLayout { } fun setTypists(typists: List
) { - if (typists.isEmpty()) { binding.typingIndicator.root.stopAnimation(); return } - binding.typingIndicator.root.startAnimation() + if (typists.isEmpty()) { stopAnimation(); return } + startAnimation() } + + fun startAnimation() = binding.typingIndicator.root.startAnimation() + fun stopAnimation() = binding.typingIndicator.root.stopAnimation() } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt deleted file mode 100644 index 451dcc3031..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.dialogs - -import android.app.Dialog -import android.graphics.Typeface -import android.os.Bundle -import android.text.Spannable -import android.text.SpannableStringBuilder -import android.text.style.StyleSpan -import androidx.fragment.app.DialogFragment -import network.loki.messenger.R -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY -import org.thoughtcrime.securesms.createSessionDialog -import org.thoughtcrime.securesms.ui.getSubbedCharSequence - -/** Shown upon sending a message to a user that's blocked. */ -class BlockedDialog(private val recipient: Address, private val contactName: String) : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { - val explanationCS = context.getSubbedCharSequence(R.string.blockUnblockName, NAME_KEY to contactName) - val spannable = SpannableStringBuilder(explanationCS) - val startIndex = explanationCS.indexOf(contactName) - spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + contactName.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - - title(resources.getString(R.string.blockUnblock)) - text(spannable) - dangerButton(R.string.blockUnblock, R.string.AccessibilityId_unblockConfirm) { unblock() } - cancelButton { dismiss() } - } - - private fun unblock() { - MessagingModuleConfiguration.shared.storage.setBlocked(listOf(recipient), false) - dismiss() - } -} 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 38f90ae06a..82a5ce2ecd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt @@ -7,7 +7,6 @@ import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import org.session.libsession.database.StorageProtocol -import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState @@ -16,7 +15,6 @@ import org.session.libsession.utilities.StringSubstitutionConstants.CONVERSATION import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.displayName import org.thoughtcrime.securesms.createSessionDialog -import org.thoughtcrime.securesms.database.SessionContactDatabase import javax.inject.Inject /** Shown when receiving media from a contact for the first time, to confirm that @@ -27,7 +25,6 @@ class AutoDownloadDialog(private val threadRecipient: Recipient, ) : DialogFragment() { @Inject lateinit var storage: StorageProtocol - @Inject lateinit var contactDB: SessionContactDatabase @Inject lateinit var attachmentDownloadJobFactory: AttachmentDownloadJob.Factory override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { @@ -50,7 +47,7 @@ class AutoDownloadDialog(private val threadRecipient: Recipient, val attachmentId = databaseAttachment.attachmentId.rowId if (databaseAttachment.transferState == AttachmentState.PENDING.value - && MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) { + && storage.getAttachmentUploadJob(attachmentId) == null) { // start download JobQueue.shared.add(attachmentDownloadJobFactory.create(attachmentId, databaseAttachment.mmsId)) } 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 e566ad9856..ac74fb1352 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 @@ -249,7 +249,7 @@ class InputBar @JvmOverloads constructor( binding.inputBarAdditionalContentContainer.addView(layout) val attachments = (message as? MmsMessageRecord)?.slideDeck val sender = - if (message.isOutgoing) recipientRepository.getRecipientSync(Address.fromSerialized(TextSecurePreferences.getLocalNumber(context)!!)) + if (message.isOutgoing) recipientRepository.getSelf() else message.individualRecipient it.bind(sender, message.body, attachments, thread, true, message.isOpenGroupInvitation, message.threadId, false, glide) } @@ -365,7 +365,7 @@ class InputBar @JvmOverloads constructor( fun setCharLimitState(state: InputbarViewModel.InputBarCharLimitState?) { // handle char limit if(state != null){ - binding.characterLimitText.text = state.count.toString() + binding.characterLimitText.text = state.countFormatted binding.characterLimitText.setTextColor(if(state.danger) dangerColor else textColor) binding.characterLimitContainer.setOnClickListener { delegate?.onCharLimitTapped() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt index 6394279141..5e4f71b93b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt @@ -30,8 +30,10 @@ import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.isGroup import org.session.libsession.utilities.isGroupOrCommunity import org.session.libsession.utilities.recipients.displayName +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate import org.thoughtcrime.securesms.dependencies.DatabaseComponent @@ -72,6 +74,8 @@ class ControlMessageView : LinearLayout { @Inject lateinit var disappearingMessages: DisappearingMessages @Inject lateinit var dateUtils: DateUtils @Inject lateinit var recipientRepository: RecipientRepository + @Inject lateinit var loginStateRepository: LoginStateRepository + @Inject lateinit var threadDatabase: ThreadDatabase val controlContentView: View get() = binding.controlContentView @@ -135,9 +139,9 @@ class ControlMessageView : LinearLayout { } message.isMessageRequestResponse -> { val msgRecipient = message.recipient.address.toString() - val me = TextSecurePreferences.getLocalNumber(context) + val me = loginStateRepository.getLocalNumber() binding.textView.text = if (me == msgRecipient) { // you accepted the user's request - DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(message.threadId) + threadDatabase.getRecipientForThreadId(message.threadId) ?.let { recipientRepository.getRecipientSync(it) } ?.let { recipient -> context.getSubbedCharSequence( R.string.messageRequestYouHaveAccepted, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.kt index 9c7df9dd4a..3734617c7e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.kt @@ -4,27 +4,30 @@ import android.content.Context import android.os.Handler import android.os.Looper import android.util.AttributeSet -import android.view.* +import android.view.HapticFeedbackConstants +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View import android.view.View.OnTouchListener +import android.view.ViewGroup import android.widget.LinearLayout import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import androidx.core.view.setPadding import com.google.android.flexbox.JustifyContent -import com.squareup.phrase.Phrase import network.loki.messenger.R import network.loki.messenger.databinding.ViewEmojiReactionsBinding -import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY -import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber import org.session.libsession.utilities.ThemeUtil +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.components.emoji.EmojiImageView import org.thoughtcrime.securesms.components.emoji.EmojiUtil import org.thoughtcrime.securesms.conversation.v2.ViewUtil import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.util.NumberUtil.getFormattedNumber -import java.util.* +import java.util.Date class EmojiReactionsView : ConstraintLayout, OnTouchListener { companion object { @@ -66,7 +69,11 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { binding.layoutEmojiContainer.removeAllViews() } - fun setReactions(messageId: MessageId, records: List, outgoing: Boolean, delegate: VisibleMessageViewDelegate?) { + fun setReactions(messageId: MessageId, + threadRecipient: Recipient, + records: List, + outgoing: Boolean, + delegate: VisibleMessageViewDelegate?) { this.delegate = delegate if (records == this.records) { return @@ -79,7 +86,7 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { extended = false } this.messageId = messageId - displayReactions(messageId, if (extended) Int.MAX_VALUE else DEFAULT_THRESHOLD) + displayReactions(messageId, threadRecipient, if (extended) Int.MAX_VALUE else DEFAULT_THRESHOLD) } override fun onTouch(v: View, event: MotionEvent): Boolean { @@ -92,9 +99,18 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { return true } - private fun displayReactions(messageId: MessageId, threshold: Int) { - val userPublicKey = getLocalNumber(context) - val reactions = buildSortedReactionsList(messageId, records!!, userPublicKey, threshold) + private fun displayReactions(messageId: MessageId, threadRecipient: Recipient, threshold: Int) { + val userPublicKey = (context.applicationContext as ApplicationContext).loginStateRepository + .get() + .getLocalNumber() + + val reactions = buildSortedReactionsList( + messageId = messageId, + threadRecipient = threadRecipient, + records = records!!, + userPublicKey = userPublicKey, + threshold = threshold + ) binding.layoutEmojiContainer.removeAllViews() val overflowContainer = LinearLayout(context) overflowContainer.orientation = LinearLayout.HORIZONTAL @@ -111,7 +127,7 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { val pill = buildPill(context, this, reaction, true) pill.setOnClickListener { v: View? -> extended = true - displayReactions(messageId, Int.MAX_VALUE) + displayReactions(messageId, threadRecipient, Int.MAX_VALUE) } pill.findViewById(R.id.reactions_pill_count).visibility = GONE pill.findViewById(R.id.reactions_pill_spacer).visibility = GONE @@ -143,7 +159,7 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { for (id in binding.groupShowLess.referencedIds) { findViewById(id).setOnClickListener { view: View? -> extended = false - displayReactions(messageId, DEFAULT_THRESHOLD) + displayReactions(messageId, threadRecipient, DEFAULT_THRESHOLD) } } } else { @@ -151,28 +167,46 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { } } - private fun buildSortedReactionsList(messageId: MessageId, records: List, userPublicKey: String?, threshold: Int): List { - val counters: MutableMap = LinkedHashMap() + private fun buildSortedReactionsList( + messageId: MessageId, + threadRecipient: Recipient, + records: List, + userPublicKey: String?, + threshold: Int + ): List { + val reactions = arrayListOf() - records.forEach { - val baseEmoji = EmojiUtil.getCanonicalRepresentation(it.emoji) - val info = counters[baseEmoji] + val accumulateReactionCount = !threadRecipient.isCommunityRecipient - if (info == null) { - counters[baseEmoji] = Reaction(messageId, it.emoji, it.count, it.sortId, it.dateReceived, userPublicKey == it.author) - } - else { - info.update(it.emoji, it.count, it.dateReceived, userPublicKey == it.author) + // First go through reaction records and create a sorted list of reaction data based on emoji + for (record in records) { + val baseEmoji = EmojiUtil.getCanonicalRepresentation(record.emoji) + val index = reactions.binarySearchBy(baseEmoji) { it.emoji } + if (index >= 0) { + reactions[index].update( + count = record.count, + accumulateCount = accumulateReactionCount, + lastSeen = record.dateReceived, + userWasSender = record.author == userPublicKey + ) + } else { + val reaction = Reaction( + messageId = messageId, + emoji = record.emoji, + count = record.count, + sortIndex = record.sortId, + lastSeen = record.dateReceived, + userWasSender = record.author == userPublicKey + ) + reactions.add(-index - 1, reaction) } } - val reactions: List = ArrayList(counters.values) - Collections.sort(reactions, Collections.reverseOrder()) + // Once we have a list of unique reactions, sort them by our desired criteria + reactions.sort() - return if (reactions.size >= threshold + 2 && threshold != Int.MAX_VALUE) { - val shortened: MutableList = ArrayList(threshold + 2) - shortened.addAll(reactions.subList(0, threshold + 2)) - shortened + return if (threshold != Int.MAX_VALUE) { + reactions.take(threshold + 2) } else { reactions } @@ -190,18 +224,12 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { layoutParams.width = overflowItemSize root.layoutParams = layoutParams } - if (reaction.emoji != null) { - emojiView.setImageEmoji(reaction.emoji) - if (reaction.count >= 1) { - countView.text = getFormattedNumber(reaction.count) - } else { - countView.visibility = GONE - spacer.visibility = GONE - } + emojiView.setImageEmoji(reaction.emoji) + if (reaction.count >= 1) { + countView.text = getFormattedNumber(reaction.count) } else { - emojiView.visibility = GONE + countView.visibility = GONE spacer.visibility = GONE - countView.text = Phrase.from(context, R.string.andMore).put(COUNT_KEY, reaction.count.toInt()).format() } if (reaction.userWasSender && !isCompact) { root.background = ContextCompat.getDrawable(context, R.drawable.reaction_pill_background_selected) @@ -254,40 +282,40 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { } } - internal class Reaction( - internal val messageId: MessageId, - internal var emoji: String?, - internal var count: Long, - internal val sortIndex: Long, - internal var lastSeen: Long, - internal var userWasSender: Boolean - ) : Comparable { - fun update(emoji: String, count: Long, lastSeen: Long, userWasSender: Boolean) { - if (!this.userWasSender) { - if (userWasSender || lastSeen > this.lastSeen) { - this.emoji = emoji - } + class Reaction( + val messageId: MessageId, + val emoji: String, + var count: Long, + val sortIndex: Long, + var lastSeen: Long, + var userWasSender: Boolean, + ) : Comparable { + fun update(count: Long, accumulateCount: Boolean, lastSeen: Long, userWasSender: Boolean) { + if (accumulateCount) { + this.count += count + } else { + this.count = this.count.coerceAtLeast(count) } - this.count = this.count + count - this.lastSeen = Math.max(this.lastSeen, lastSeen) + + this.lastSeen = this.lastSeen.coerceAtLeast(lastSeen) this.userWasSender = this.userWasSender || userWasSender } - fun merge(other: Reaction): Reaction { - count = count + other.count - lastSeen = Math.max(lastSeen, other.lastSeen) - userWasSender = userWasSender || other.userWasSender - return this - } + override fun compareTo(other: Reaction): Int { + var rc = this.sortIndex.compareTo(other.sortIndex) + if (rc == 0) { + rc = other.count.compareTo(count) + } - override fun compareTo(other: Reaction?): Int { - if (other == null) { return -1 } + if (rc == 0) { + rc = other.lastSeen.compareTo(lastSeen) + } - if (this.count == other.count) { - return this.sortIndex.compareTo(other.sortIndex) + if (rc == 0) { + rc = this.emoji.compareTo(other.emoji) } - return this.count.compareTo(other.count) + return rc } } } \ No newline at end of file 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 5a7219a756..0780534765 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 @@ -12,18 +12,15 @@ import androidx.compose.ui.unit.dp import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.use -import androidx.core.graphics.toColor import androidx.core.text.toSpannable import androidx.core.view.isVisible import com.bumptech.glide.RequestManager import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ViewQuoteBinding -import org.session.libsession.utilities.Address import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.displayName -import org.session.libsession.utilities.recipients.shouldShowProBadge import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.mms.SlideDeck @@ -32,7 +29,6 @@ import org.thoughtcrime.securesms.ui.ProBadgeText import org.thoughtcrime.securesms.ui.proBadgeColorOutgoing import org.thoughtcrime.securesms.ui.proBadgeColorStandard import org.thoughtcrime.securesms.ui.setThemedContent -import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.bold import org.thoughtcrime.securesms.util.MediaUtil @@ -105,7 +101,7 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? modifier = modifier, text = authorDisplayName, textStyle = LocalType.current.small.bold().copy(color = Color(textColor)), - showBadge = authorRecipient.proStatus.shouldShowProBadge(), + showBadge = authorRecipient.shouldShowProBadge, badgeColors = if(isOutgoingMessage && mode == Mode.Regular) proBadgeColorOutgoing() else proBadgeColorStandard() ) 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 3f883900ed..4fc3638845 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 @@ -47,7 +47,6 @@ import org.session.libsession.utilities.modifyLayoutParams import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientData import org.session.libsession.utilities.recipients.displayName -import org.session.libsession.utilities.recipients.shouldShowProBadge import org.session.libsession.utilities.truncatedForDisplay import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.model.MessageId @@ -231,7 +230,7 @@ class VisibleMessageView : FrameLayout { text = sender.displayName(), textStyle = LocalType.current.base.bold() .copy(color = LocalColors.current.text), - showBadge = message.recipient.proStatus.shouldShowProBadge(), + showBadge = message.recipient.shouldShowProBadge, ) if (sender.address is Address.Blinded) { @@ -276,7 +275,13 @@ class VisibleMessageView : FrameLayout { val capabilities = (threadRecipient.address as? Address.Community)?.serverUrl?.let { lokiApiDb.getServerCapabilities(it) } if (capabilities.isNullOrEmpty() || capabilities.contains(OpenGroupApi.Capability.REACTIONS.name.lowercase())) { emojiReactionsBinding.value.root.let { root -> - root.setReactions(message.messageId, message.reactions, message.isOutgoing, delegate) + root.setReactions( + messageId = message.messageId, + threadRecipient = threadRecipient, + records = message.reactions, + outgoing = message.isOutgoing, + delegate = delegate + ) root.layoutParams = (root.layoutParams as ConstraintLayout.LayoutParams).apply { horizontalBias = if (message.isOutgoing) 1f else 0f } @@ -341,9 +346,11 @@ class VisibleMessageView : FrameLayout { // Set text & icons as appropriate for the message state. Note: Possible message states we care // about are: isFailed, isSyncFailed, isPending, isSyncing, isResyncing, isRead, and isSent. - messageStatus.messageText?.let{ + messageStatus.messageTextRes?.let{ binding.messageStatusTextView.setText(it) - binding.messageStatusTextView.contentDescription = context.getString(R.string.AccessibilityId_send_status) + it + binding.messageStatusTextView.contentDescription = + context.getString(R.string.AccessibilityId_send_status)+ + context.getString(it) } messageStatus.iconTint?.let(binding.messageStatusTextView::setTextColor) messageStatus.iconId?.let { ContextCompat.getDrawable(context, it) } @@ -421,7 +428,7 @@ class VisibleMessageView : FrameLayout { data class MessageStatusInfo(@DrawableRes val iconId: Int?, @ColorInt val iconTint: Int?, - @StringRes val messageText: Int?) + @StringRes val messageTextRes: Int?) private fun getMessageStatusInfo(message: MessageRecord): MessageStatusInfo? = when { message.isFailed -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsActivity.kt index 9e6058c79a..fedeadd188 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsActivity.kt @@ -7,32 +7,39 @@ import androidx.core.content.IntentCompat import dagger.hilt.android.AndroidEntryPoint import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.FullComposeScreenLockActivity -import org.thoughtcrime.securesms.ui.UINavigator -import javax.inject.Inject @AndroidEntryPoint class ConversationSettingsActivity: FullComposeScreenLockActivity() { companion object { - const val THREAD_ADDRESS = "conversation_settings_thread_address" + private const val THREAD_ADDRESS = "conversation_settings_thread_address" + private const val EXTRA_START_DESTINATION = "start_destination" - fun createIntent(context: Context, address: Address.Conversable): Intent { + fun createIntent( + context: Context, + address: Address.Conversable, + startDestination: ConversationSettingsDestination = ConversationSettingsDestination.RouteConversationSettings + ): Intent { return Intent(context, ConversationSettingsActivity::class.java).apply { putExtra(THREAD_ADDRESS, address) + putExtra(EXTRA_START_DESTINATION, startDestination) } } } - @Inject - lateinit var navigator: UINavigator - @Composable override fun ComposeContent() { + val startDestination = IntentCompat.getParcelableExtra( + intent, + EXTRA_START_DESTINATION, + ConversationSettingsDestination::class.java + ) ?: ConversationSettingsDestination.RouteConversationSettings + ConversationSettingsNavHost( address = requireNotNull(IntentCompat.getParcelableExtra(intent, THREAD_ADDRESS, Address.Conversable::class.java)) { "ConversationSettingsActivity requires an Address to be passed in the intent." }, - navigator = navigator, + startDestination = startDestination, returnResult = { code, value -> setResult(RESULT_OK, Intent().putExtra(code, value)) finish() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt index f752697802..d058fb29f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt @@ -36,13 +36,15 @@ import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsV import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.UpdateGroupDescription import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.UpdateGroupName import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.UpdateNickname +import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.CTAImage import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GenericProCTA import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.PinProCTA import org.thoughtcrime.securesms.ui.RadioOption -import org.thoughtcrime.securesms.ui.SimpleSessionProActivatedCTA +import org.thoughtcrime.securesms.ui.SessionProCTA import org.thoughtcrime.securesms.ui.components.AnnotatedTextWithIcon import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField @@ -231,6 +233,7 @@ fun ConversationSettingsDialogs( // pin CTA if(dialogsState.pinCTA != null){ PinProCTA( + proSubscription = dialogsState.pinCTA.proSubscription, overTheLimit = dialogsState.pinCTA.overTheLimit, onDismissRequest = { sendCommand(HidePinCTADialog) @@ -241,6 +244,7 @@ fun ConversationSettingsDialogs( when(dialogsState.proBadgeCTA){ is ConversationSettingsViewModel.ProBadgeCTA.Generic -> { GenericProCTA( + proSubscription = dialogsState.proBadgeCTA.proSubscription, onDismissRequest = { sendCommand(HideProBadgeCTA) } @@ -248,9 +252,9 @@ fun ConversationSettingsDialogs( } is ConversationSettingsViewModel.ProBadgeCTA.Group -> { - SimpleSessionProActivatedCTA( - heroImage = R.drawable.cta_hero_group, + SessionProCTA( title = stringResource(R.string.proGroupActivated), + badgeAtStart = true, textContent = { AnnotatedTextWithIcon( modifier = Modifier @@ -259,8 +263,12 @@ fun ConversationSettingsDialogs( iconRes = R.drawable.ic_pro_badge, iconSize = 40.sp to 18.sp, style = LocalType.current.large, + textQaTag = stringResource(R.string.qa_cta_body) ) }, + content = { CTAImage(heroImage = R.drawable.cta_hero_group) }, + positiveButtonText = null, + negativeButtonText = stringResource(R.string.close), onCancel = { sendCommand(HideProBadgeCTA) } @@ -434,7 +442,7 @@ fun PreviewCTAGroupDialog() { PreviewTheme { ConversationSettingsDialogs( dialogsState = ConversationSettingsViewModel.DialogsState( - proBadgeCTA = ConversationSettingsViewModel.ProBadgeCTA.Group + proBadgeCTA = ConversationSettingsViewModel.ProBadgeCTA.Group(ProStatus.NeverSubscribed) ), sendCommand = {} ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 291d459c04..8af681b86c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -1,12 +1,13 @@ package org.thoughtcrime.securesms.conversation.v2.settings import android.annotation.SuppressLint +import android.os.Parcelable import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.dropUnlessResumed @@ -14,6 +15,7 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import network.loki.messenger.BuildConfig import org.session.libsession.messaging.messages.ExpirationConfiguration @@ -39,11 +41,13 @@ import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.ui.horizontalSlideComposable // Destinations -sealed interface ConversationSettingsDestination { +sealed interface ConversationSettingsDestination: Parcelable { @Serializable + @Parcelize data object RouteConversationSettings: ConversationSettingsDestination @Serializable + @Parcelize data class RouteGroupMembers private constructor( private val address: String ): ConversationSettingsDestination { @@ -53,6 +57,7 @@ sealed interface ConversationSettingsDestination { } @Serializable + @Parcelize data class RouteManageMembers private constructor( private val address: String ): ConversationSettingsDestination { @@ -62,6 +67,7 @@ sealed interface ConversationSettingsDestination { } @Serializable + @Parcelize data class RouteInviteToGroup private constructor( private val address: String, val excludingAccountIDs: List @@ -73,15 +79,19 @@ sealed interface ConversationSettingsDestination { } @Serializable + @Parcelize data object RouteDisappearingMessages: ConversationSettingsDestination @Serializable + @Parcelize data object RouteAllMedia: ConversationSettingsDestination @Serializable + @Parcelize data object RouteNotifications: ConversationSettingsDestination @Serializable + @Parcelize data class RouteInviteToCommunity( val communityUrl: String ): ConversationSettingsDestination @@ -92,12 +102,21 @@ sealed interface ConversationSettingsDestination { @Composable fun ConversationSettingsNavHost( address: Address.Conversable, - navigator: UINavigator, + startDestination: ConversationSettingsDestination = RouteConversationSettings, returnResult: (String, Boolean) -> Unit, onBack: () -> Unit ){ SharedTransitionLayout { val navController = rememberNavController() + val navigator: UINavigator = remember { UINavigator() } + + val handleBack: () -> Unit = { + if (navController.previousBackStackEntry != null) { + navController.navigateUp() + } else { + onBack() // Finish activity if at root + } + } ObserveAsEvents(flow = navigator.navigationActions) { action -> when (action) { @@ -107,7 +126,7 @@ fun ConversationSettingsNavHost( action.navOptions(this) } - NavigationAction.NavigateUp -> navController.navigateUp() + NavigationAction.NavigateUp -> handleBack() is NavigationAction.NavigateToIntent -> { navController.context.startActivity(action.intent) @@ -116,15 +135,17 @@ fun ConversationSettingsNavHost( is NavigationAction.ReturnResult -> { returnResult(action.code, action.value) } + + else -> {} } } - NavHost(navController = navController, startDestination = RouteConversationSettings) { + NavHost(navController = navController, startDestination = startDestination) { // Conversation Settings horizontalSlideComposable { val viewModel = hiltViewModel { factory -> - factory.create(address) + factory.create(address, navigator) } val lifecycleOwner = LocalLifecycleOwner.current @@ -154,7 +175,7 @@ fun ConversationSettingsNavHost( GroupMembersScreen( viewModel = viewModel, onBack = dropUnlessResumed { - navController.popBackStack() + handleBack() }, ) } @@ -178,7 +199,7 @@ fun ConversationSettingsNavHost( ) }, onBack = dropUnlessResumed { - navController.popBackStack() + handleBack() }, ) } @@ -208,10 +229,10 @@ fun ConversationSettingsNavHost( //send invites from the manage group screen editGroupViewModel.onContactSelected(viewModel.currentSelected) - navController.popBackStack() + handleBack() }, onBack = dropUnlessResumed { - navController.popBackStack() + handleBack() }, banner = { GroupMinimumVersionBanner() @@ -244,7 +265,7 @@ fun ConversationSettingsNavHost( viewModel.clearSelection() }, onBack = dropUnlessResumed { - navController.popBackStack() + handleBack() }, ) } @@ -256,14 +277,15 @@ fun ConversationSettingsNavHost( factory.create( address = address, isNewConfigEnabled = ExpirationConfiguration.isNewConfigEnabled, - showDebugOptions = BuildConfig.BUILD_TYPE != "release" + showDebugOptions = BuildConfig.BUILD_TYPE != "release", + navigator = navigator ) } DisappearingMessagesScreen( viewModel = viewModel, onBack = dropUnlessResumed { - navController.popBackStack() + handleBack() }, ) } @@ -278,7 +300,7 @@ fun ConversationSettingsNavHost( MediaOverviewScreen( viewModel = viewModel, onClose = dropUnlessResumed { - navController.popBackStack() + handleBack() }, ) } @@ -293,7 +315,7 @@ fun ConversationSettingsNavHost( NotificationSettingsScreen( viewModel = viewModel, onBack = dropUnlessResumed { - navController.popBackStack() + handleBack() } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index de86d4cec5..b54cba5abc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -11,6 +11,7 @@ import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity.CLIPBOARD_SERVICE import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel import com.squareup.phrase.Phrase import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -23,13 +24,12 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.PRIORITY_HIDDEN +import network.loki.messenger.libsession_util.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 @@ -45,8 +45,6 @@ import org.session.libsession.utilities.isGroupOrCommunity import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientData import org.session.libsession.utilities.recipients.displayName -import org.session.libsession.utilities.recipients.isPro -import org.session.libsession.utilities.recipients.shouldShowProBadge import org.session.libsession.utilities.updateContact import org.session.libsession.utilities.upsertContact import org.session.libsignal.utilities.AccountId @@ -59,6 +57,7 @@ import org.thoughtcrime.securesms.dependencies.ConfigFactory.Companion.MAX_GROUP import org.thoughtcrime.securesms.dependencies.ConfigFactory.Companion.MAX_NAME_BYTES import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.home.HomeActivity +import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.ui.SimpleDialogData @@ -72,13 +71,13 @@ import org.thoughtcrime.securesms.util.AvatarUtils @HiltViewModel(assistedFactory = ConversationSettingsViewModel.Factory::class) class ConversationSettingsViewModel @AssistedInject constructor( @Assisted private val address: Address.Conversable, + @Assisted private val navigator: UINavigator, @param:ApplicationContext private val context: Context, private val avatarUtils: AvatarUtils, private val repository: ConversationRepository, private val configFactory: ConfigFactoryProtocol, private val storage: StorageProtocol, private val conversationRepository: ConversationRepository, - private val navigator: UINavigator, private val groupManagerV2: GroupManagerV2, private val groupManager: GroupManagerV2, private val openGroupManager: OpenGroupManager, @@ -395,7 +394,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( } conversation.data is RecipientData.Group -> { - conversation.data.partial.description to // description + conversation.data.description to // description context.getString(R.string.qa_conversation_settings_description_groups) // description qa tag } @@ -531,7 +530,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( conversation.data is RecipientData.Group -> { // if the user is kicked or the group destroyed, only show "Delete Group" - if (!conversation.data.partial.shouldPoll){ + if (!conversation.data.shouldPoll){ listOf( OptionsCategory( items = listOf( @@ -656,10 +655,10 @@ class ConversationSettingsViewModel @AssistedInject constructor( else -> emptyList() } - val showProBadge = conversation.proStatus.shouldShowProBadge() && !conversation.isLocalNumber + val showProBadge = conversation.shouldShowProBadge && !conversation.isLocalNumber // if it's a one on one convo and the user isn't pro themselves - val proBadgeClickable = if(conversation.is1on1 && myself.proStatus.isPro()) false + val proBadgeClickable = if(conversation.is1on1 && myself.isPro) false else showProBadge // otherwise whenever the badge is shown val avatarData = avatarUtils.getUIDataFromRecipient(conversation) @@ -715,11 +714,17 @@ class ConversationSettingsViewModel @AssistedInject constructor( private fun pinConversation(){ // check the pin limit before continuing val totalPins = storage.getTotalPinned() - val maxPins = proStatusManager.getPinnedConversationLimit(recipientRepository.getSelf().proStatus) - if(totalPins >= maxPins){ + val maxPins = + proStatusManager.getPinnedConversationLimit(recipientRepository.getSelf().isPro) + if (totalPins >= maxPins) { // the user has reached the pin limit, show the CTA _dialogState.update { - it.copy(pinCTA = PinProCTA(overTheLimit = totalPins > maxPins)) + it.copy( + pinCTA = PinProCTA( + overTheLimit = totalPins > maxPins, + proSubscription = proStatusManager.proDataState.value.type + ) + ) } } else { viewModelScope.launch { @@ -1236,8 +1241,8 @@ class ConversationSettingsViewModel @AssistedInject constructor( is Commands.ShowProBadgeCTA -> { _dialogState.update { it.copy( - proBadgeCTA = if(recipient?.isGroupV2Recipient == true) ProBadgeCTA.Group - else ProBadgeCTA.Generic + proBadgeCTA = if(recipient?.isGroupV2Recipient == true) ProBadgeCTA.Group(proStatusManager.proDataState.value.type) + else ProBadgeCTA.Generic(proStatusManager.proDataState.value.type) ) } } @@ -1385,7 +1390,10 @@ class ConversationSettingsViewModel @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(address: Address.Conversable): ConversationSettingsViewModel + fun create( + address: Address.Conversable, + navigator: UINavigator + ): ConversationSettingsViewModel } data class UIState( @@ -1438,12 +1446,13 @@ class ConversationSettingsViewModel @AssistedInject constructor( ) data class PinProCTA( - val overTheLimit: Boolean + val overTheLimit: Boolean, + val proSubscription: ProStatus ) - sealed interface ProBadgeCTA { - data object Generic: ProBadgeCTA - data object Group: ProBadgeCTA + sealed class ProBadgeCTA(open val proSubscription: ProStatus) { + data class Generic(override val proSubscription: ProStatus): ProBadgeCTA(proSubscription) + data class Group(override val proSubscription: ProStatus): ProBadgeCTA(proSubscription) } data class NicknameDialogData( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsActivity.kt index 7c0bad06ca..623289c543 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsActivity.kt @@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.conversation.v2.settings.notification import androidx.compose.runtime.Composable import androidx.core.content.IntentCompat -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import dagger.hilt.android.AndroidEntryPoint import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.FullComposeScreenLockActivity diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt index 2f47041204..1e3fb60b8c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt @@ -1,6 +1,6 @@ package org.thoughtcrime.securesms.conversation.v2.utilities -import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.visible.LinkPreview import org.session.libsession.messaging.messages.visible.OpenGroupInvitation @@ -12,8 +12,12 @@ import org.session.libsession.utilities.isGroupOrCommunity import org.session.libsession.utilities.toGroupString import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import javax.inject.Inject -object ResendMessageUtilities { +class ResendMessageUtilities @Inject constructor( + private val messageSender: MessageSender, + private val storage: StorageProtocol, +) { suspend fun resend(accountId: String?, messageRecord: MessageRecord, userBlindedKey: String?, isResync: Boolean = false) { val recipient = messageRecord.recipient.address @@ -51,14 +55,14 @@ object ResendMessageUtilities { message.addSignalAttachments(messageRecord.slideDeck.asAttachments()) } val sentTimestamp = message.sentTimestamp - val sender = MessagingModuleConfiguration.shared.storage.getUserPublicKey() + val sender = storage.getUserPublicKey() if (sentTimestamp != null && sender != null) { if (isResync) { - MessagingModuleConfiguration.shared.storage.markAsResyncing(messageRecord.messageId) - MessageSender.sendNonDurably(message, Destination.from(recipient), isSyncMessage = true) + storage.markAsResyncing(messageRecord.messageId) + messageSender.sendNonDurably(message, Destination.from(recipient), isSyncMessage = true) } else { - MessagingModuleConfiguration.shared.storage.markAsSending(messageRecord.messageId) - MessageSender.send(message, recipient) + storage.markAsSending(messageRecord.messageId) + messageSender.send(message, recipient) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java index 52826a2838..e262f18022 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java @@ -20,7 +20,6 @@ import android.content.Context; import android.content.SharedPreferences; import android.content.SharedPreferences.Editor; -import android.os.Build; import androidx.annotation.NonNull; @@ -31,7 +30,6 @@ import org.session.libsignal.crypto.ecc.DjbECPublicKey; import org.session.libsignal.crypto.ecc.ECKeyPair; import org.session.libsignal.crypto.ecc.ECPrivateKey; -import org.session.libsignal.crypto.ecc.ECPublicKey; import org.session.libsignal.exceptions.InvalidKeyException; import org.session.libsignal.utilities.Base64; @@ -40,7 +38,6 @@ import kotlin.Unit; import kotlinx.coroutines.channels.BufferOverflow; import kotlinx.coroutines.flow.MutableSharedFlow; -import kotlinx.coroutines.flow.MutableStateFlow; import kotlinx.coroutines.flow.SharedFlowKt; import network.loki.messenger.libsession_util.Curve25519; import network.loki.messenger.libsession_util.util.KeyPair; @@ -50,6 +47,7 @@ * * @author Moxie Marlinspike */ +@Deprecated(forRemoval = true) public class IdentityKeyUtil { private static final String MASTER_SECRET_UTIL_PREFERENCES_NAME = "SecureSMS-Preferences"; @@ -63,7 +61,7 @@ public class IdentityKeyUtil { public static final String ED25519_PUBLIC_KEY = "pref_ed25519_public_key"; public static final String ED25519_SECRET_KEY = "pref_ed25519_secret_key"; public static final String NOTIFICATION_KEY = "pref_notification_key"; - public static final String LOKI_SEED = "loki_seed"; + private static final String LOKI_SEED = "loki_seed"; public static final String HAS_MIGRATED_KEY = "has_migrated_keys"; public static final MutableSharedFlow CHANGES = SharedFlowKt.MutableSharedFlow(0, 1, BufferOverflow.DROP_LATEST); diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyPairUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyPairUtilities.kt deleted file mode 100644 index bd90022bad..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyPairUtilities.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.thoughtcrime.securesms.crypto - -import android.content.Context -import network.loki.messenger.libsession_util.Curve25519 -import network.loki.messenger.libsession_util.ED25519 -import network.loki.messenger.libsession_util.util.KeyPair -import org.session.libsignal.crypto.ecc.DjbECPrivateKey -import org.session.libsignal.crypto.ecc.DjbECPublicKey -import org.session.libsignal.crypto.ecc.ECKeyPair -import org.session.libsignal.utilities.Base64 -import org.session.libsignal.utilities.Hex -import java.security.SecureRandom - -object KeyPairUtilities { - - fun generate(): KeyPairGenerationResult { - val seed = ByteArray(16).also { - SecureRandom().nextBytes(it) - } - - return generate(seed) - } - - fun generate(seed: ByteArray): KeyPairGenerationResult { - val paddedSeed = seed + ByteArray(16) - val ed25519KeyPair = ED25519.generate(paddedSeed) - val x25519KeyPair = Curve25519.fromED25519(ed25519KeyPair) - return KeyPairGenerationResult(seed, ed25519KeyPair, - ECKeyPair(DjbECPublicKey(x25519KeyPair.pubKey.data), DjbECPrivateKey(x25519KeyPair.secretKey.data))) - } - - fun store(context: Context, seed: ByteArray, ed25519KeyPair: KeyPair, x25519KeyPair: ECKeyPair) { - IdentityKeyUtil.save(context, IdentityKeyUtil.LOKI_SEED, Hex.toStringCondensed(seed)) - IdentityKeyUtil.save(context, IdentityKeyUtil.IDENTITY_PUBLIC_KEY_PREF, Base64.encodeBytes(x25519KeyPair.publicKey.serialize())) - IdentityKeyUtil.save(context, IdentityKeyUtil.IDENTITY_PRIVATE_KEY_PREF, Base64.encodeBytes(x25519KeyPair.privateKey.serialize())) - IdentityKeyUtil.save(context, IdentityKeyUtil.ED25519_PUBLIC_KEY, Base64.encodeBytes(ed25519KeyPair.pubKey.data)) - IdentityKeyUtil.save(context, IdentityKeyUtil.ED25519_SECRET_KEY, Base64.encodeBytes(ed25519KeyPair.secretKey.data)) - } - - fun getUserED25519KeyPair(context: Context): KeyPair? { - val base64EncodedED25519PublicKey = IdentityKeyUtil.retrieve(context, IdentityKeyUtil.ED25519_PUBLIC_KEY) ?: return null - val base64EncodedED25519SecretKey = IdentityKeyUtil.retrieve(context, IdentityKeyUtil.ED25519_SECRET_KEY) ?: return null - return KeyPair(pubKey = Base64.decode(base64EncodedED25519PublicKey), secretKey = Base64.decode(base64EncodedED25519SecretKey)) - } - - data class KeyPairGenerationResult( - val seed: ByteArray, - val ed25519KeyPair: KeyPair, - val x25519KeyPair: ECKeyPair - ) -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/BlindMappingRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/database/BlindMappingRepository.kt index fe1959dc78..75c7f53cf3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/BlindMappingRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/BlindMappingRepository.kt @@ -5,8 +5,6 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn @@ -18,6 +16,7 @@ import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.userConfigsChanged import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.util.castAwayType @@ -33,7 +32,7 @@ private typealias CommunityServerUrl = String @Singleton class BlindMappingRepository @Inject constructor( private val configFactory: ConfigFactory, - prefs: TextSecurePreferences, + loginStateRepository: LoginStateRepository, @param:ManagerScope private val scope: CoroutineScope, ) { @@ -42,29 +41,25 @@ class BlindMappingRepository @Inject constructor( * blinded addresses to 05 prefixed addresses */ @Suppress("OPT_IN_USAGE") - val mappings: StateFlow>> = prefs.watchLocalNumber() - .flatMapLatest { localAddress -> - if (localAddress.isNullOrBlank()) { - emptyFlow() - } else { - configFactory - .userConfigsChanged(setOf(UserConfigType.USER_GROUPS, UserConfigType.CONTACTS)) - .castAwayType() - .onStart { emit(Unit) } - .map { - configFactory.withUserConfigs { configs -> - Pair( - configs.userGroups.allCommunityInfo().map { it.community }, - configs.contacts.all().map { Address.Standard(AccountId(it.id)) } - + Address.Standard(AccountId(localAddress)) - ) - } + val mappings: StateFlow>> = + loginStateRepository.flowWithLoggedInState { + configFactory + .userConfigsChanged(setOf(UserConfigType.USER_GROUPS, UserConfigType.CONTACTS)) + .castAwayType() + .onStart { emit(Unit) } + .map { + configFactory.withUserConfigs { configs -> + Pair( + configs.userGroups.allCommunityInfo().map { it.community }, + configs.contacts.all().map { Address.Standard(AccountId(it.id)) } + + Address.Standard(loginStateRepository.requireLocalAccountId()) + ) } - } + } } .distinctUntilChanged() .map { (allCommunities, allContacts) -> - allCommunities.asSequence() + allCommunities .associate { community -> community.baseUrl to allContacts.asSequence() .flatMap { contactAddress -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index befcd45ed8..e8aff961b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -23,6 +23,7 @@ import org.session.libsignal.database.LokiOpenGroupDatabaseProtocol; import org.session.libsignal.messages.SignalServiceAttachmentPointer; import org.session.libsignal.utilities.guava.Optional; +import org.thoughtcrime.securesms.auth.LoginStateRepository; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.util.BitmapUtil; @@ -108,8 +109,12 @@ public static String getCreateUpdatedTimestampCommand() { private final MutableSharedFlow updateNotification = SharedFlowKt.MutableSharedFlow(0, 128, BufferOverflow.DROP_OLDEST); - public GroupDatabase(Context context, Provider databaseHelper) { + private final LoginStateRepository loginStateRepository; + + public GroupDatabase(Context context, Provider databaseHelper, LoginStateRepository loginStateRepository) { super(context, databaseHelper); + + this.loginStateRepository = loginStateRepository; } @NonNull @@ -170,7 +175,7 @@ public List getAllGroups(boolean includeInactive) { List
filtered = new ArrayList<>(); for (Address member : members) { - if (!includeSelf && Util.isOwnNumber(context, member.toString())) + if (!includeSelf && member.getAddress().equals(loginStateRepository.getLocalNumber())) continue; if (AddressKt.isStandard(member)) { @@ -184,7 +189,7 @@ public List getAllGroups(boolean includeInactive) { public @NonNull List
getGroupMemberAddresses(String groupId, boolean includeSelf) { List
members = getCurrentMembers(groupId, false); if (!includeSelf) { - String ownNumber = TextSecurePreferences.getLocalNumber(context); + String ownNumber = loginStateRepository.getLocalNumber(); if (ownNumber == null) return members; Address ownAddress = Address.fromSerialized(ownNumber); int indexOfSelf = members.indexOf(ownAddress); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt index 6a16e68a7d..6f2e3ec679 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt @@ -49,6 +49,8 @@ class LokiAPIDatabase(context: Context, helper: Provider) : = "CREATE TABLE $legacyLastMessageHashValueTable2 ($snode TEXT, $publicKey TEXT, $lastMessageHashValue TEXT, PRIMARY KEY ($snode, $publicKey));" // Received message hash values private const val legacyReceivedMessageHashValuesTable3 = "received_message_hash_values_table_3" + + @Deprecated("This table is deleted and replaced by ReceivedMessageHashDatabase") private const val receivedMessageHashValuesTable = "session_received_message_hash_values_table" private const val receivedMessageHashValues = "received_message_hash_values" private const val receivedMessageHashNamespace = "received_message_namespace" @@ -128,6 +130,7 @@ class LokiAPIDatabase(context: Context, helper: Provider) : const val INSERT_LAST_HASH_DATA = "INSERT OR IGNORE INTO $lastMessageHashValueTable2($snode, $publicKey, $lastMessageHashValue) SELECT $snode, $publicKey, $lastMessageHashValue FROM $legacyLastMessageHashValueTable2;" const val DROP_LEGACY_LAST_HASH = "DROP TABLE $legacyLastMessageHashValueTable2;" + @Deprecated("This table is deleted and replaced by ReceivedMessageHashDatabase, keeping here just for migration purpose") const val UPDATE_RECEIVED_INCLUDE_NAMESPACE_COMMAND = """ CREATE TABLE IF NOT EXISTS $receivedMessageHashValuesTable( $publicKey STRING, $receivedMessageHashValues TEXT, $receivedMessageHashNamespace INTEGER DEFAULT 0, PRIMARY KEY ($publicKey, $receivedMessageHashNamespace) @@ -311,43 +314,6 @@ class LokiAPIDatabase(context: Context, helper: Provider) : database.delete(lastMessageHashValueTable2, null, null) } - override fun getReceivedMessageHashValues(publicKey: String, namespace: Int): Set? { - val database = readableDatabase - val query = "${Companion.publicKey} = ? AND ${Companion.receivedMessageHashNamespace} = ?" - return database.get(receivedMessageHashValuesTable, query, arrayOf( publicKey, namespace.toString() )) { cursor -> - val receivedMessageHashValuesAsString = cursor.getString(cursor.getColumnIndexOrThrow(Companion.receivedMessageHashValues)) - receivedMessageHashValuesAsString.split("-").toSet() - } - } - - override fun setReceivedMessageHashValues(publicKey: String, newValue: Set, namespace: Int) { - val database = writableDatabase - val receivedMessageHashValuesAsString = newValue.joinToString("-") - val row = wrap(mapOf( - Companion.publicKey to publicKey, - Companion.receivedMessageHashValues to receivedMessageHashValuesAsString, - Companion.receivedMessageHashNamespace to namespace.toString() - )) - val query = "${Companion.publicKey} = ? AND $receivedMessageHashNamespace = ?" - database.insertOrUpdate(receivedMessageHashValuesTable, row, query, arrayOf( publicKey, namespace.toString() )) - } - - override fun clearReceivedMessageHashValues(publicKey: String) { - writableDatabase - .delete(receivedMessageHashValuesTable, "${Companion.publicKey} = ?", arrayOf(publicKey)) - } - - override fun clearReceivedMessageHashValues() { - val database = writableDatabase - database.delete(receivedMessageHashValuesTable, null, null) - } - - override fun clearReceivedMessageHashValuesByNamespaces(vararg namespaces: Int) { - // Note that we don't use SQL parameter as the given namespaces are integer anyway so there's little chance of SQL injection - writableDatabase - .delete(receivedMessageHashValuesTable, "$receivedMessageHashNamespace IN (${namespaces.joinToString(",")})", null) - } - override fun getAuthToken(server: String): String? { val database = readableDatabase return database.get(openGroupAuthTokenTable, "${Companion.server} = ?", wrap(server)) { cursor -> @@ -480,11 +446,6 @@ class LokiAPIDatabase(context: Context, helper: Provider) : TextSecurePreferences.setLastSnodePoolRefreshDate(context, date) } - override fun getUserX25519KeyPair(): ECKeyPair { - val keyPair = IdentityKeyUtil.getIdentityKeyPair(context) - return ECKeyPair(DjbECPublicKey(keyPair.publicKey.serialize().removingIdPrefixIfNeeded()), DjbECPrivateKey(keyPair.privateKey.serialize())) - } - fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) { val database = writableDatabase val index = "$groupPublicKey-$timestamp" diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java index 5e3e52eb2d..3ac8650ba8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java @@ -9,13 +9,9 @@ import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Document; -import org.session.libsession.utilities.IdentityKeyMismatch; -import org.session.libsession.utilities.IdentityKeyMismatchList; -import org.session.libsignal.crypto.IdentityKey; import org.session.libsignal.utilities.JsonUtil; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; -import org.thoughtcrime.securesms.database.model.MessageRecord; import java.io.IOException; import java.util.ArrayList; @@ -57,29 +53,8 @@ public MessagingDatabase(Context context, Provider database public abstract void updateThreadId(long fromId, long toId); - public abstract MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException; - public abstract String getTypeColumn(); - public void addMismatchedIdentity(long messageId, Address address, IdentityKey identityKey) { - try { - addToDocument(messageId, MISMATCHED_IDENTITIES, - new IdentityKeyMismatch(address, identityKey), - IdentityKeyMismatchList.class); - } catch (IOException e) { - Log.w(TAG, e); - } - } - - public void removeMismatchedIdentity(long messageId, Address address, IdentityKey identityKey) { - try { - removeFromDocument(messageId, MISMATCHED_IDENTITIES, - new IdentityKeyMismatch(address, identityKey), - IdentityKeyMismatchList.class); - } catch (IOException e) { - Log.w(TAG, e); - } - } protected , I> void removeFromDocument(long messageId, String column, I object, Class clazz) throws IOException { SQLiteDatabase database = getWritableDatabase(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index 383210241d..70a4e50947 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -19,6 +19,7 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import android.database.Cursor +import androidx.sqlite.db.SupportSQLiteDatabase import com.annimon.stream.Stream import dagger.Lazy import dagger.hilt.android.qualifiers.ApplicationContext @@ -28,27 +29,18 @@ import org.json.JSONException import org.json.JSONObject import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.signal.IncomingMediaMessage -import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage -import org.session.libsession.messaging.messages.signal.OutgoingSecureMediaMessage import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview -import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.toAddress -import org.session.libsession.utilities.Contact -import org.session.libsession.utilities.IdentityKeyMismatch -import org.session.libsession.utilities.IdentityKeyMismatchList -import org.session.libsession.utilities.NetworkFailure -import org.session.libsession.utilities.NetworkFailureList import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled import org.session.libsession.utilities.isGroupOrCommunity import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.toGroupString -import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ThreadUtils.queue import org.session.libsignal.utilities.guava.Optional @@ -63,6 +55,10 @@ import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpda import org.thoughtcrime.securesms.database.model.content.MessageContent import org.thoughtcrime.securesms.mms.MmsException import org.thoughtcrime.securesms.mms.SlideDeck +import org.thoughtcrime.securesms.pro.toProMessageBitSetValue +import org.thoughtcrime.securesms.pro.toProMessageFeatures +import org.thoughtcrime.securesms.pro.toProProfileBitSetValue +import org.thoughtcrime.securesms.pro.toProProfileFeatures import org.thoughtcrime.securesms.util.asSequence import java.io.Closeable import java.io.IOException @@ -121,6 +117,32 @@ class MmsDatabase @Inject constructor( .any { MmsSmsColumns.Types.isOutgoingMessageType(it) } } + fun getOutgoingMessageProFeatureCount(featureMask: Long): Int { + return getOutgoingProFeatureCountInternal(PRO_MESSAGE_FEATURES, featureMask) + } + + fun getOutgoingProfileProFeatureCount(featureMask: Long): Int { + return getOutgoingProFeatureCountInternal(PRO_PROFILE_FEATURES, featureMask) + } + + private fun getOutgoingProFeatureCountInternal(column: String, featureMask: Long): Int { + val db = readableDatabase + val outgoingTypes = MmsSmsColumns.Types.OUTGOING_MESSAGE_TYPES.joinToString(",") + + // outgoing clause + val outgoingSelection = + "($MESSAGE_BOX & ${MmsSmsColumns.Types.BASE_TYPE_MASK}) IN ($outgoingTypes)" + + val where = "($column & $featureMask) != 0 AND $outgoingSelection" + + db.query(TABLE_NAME, arrayOf("COUNT(*)"), where, null, null, null, null).use { cursor -> + if (cursor.moveToFirst()) { + return cursor.getInt(0) + } + } + return 0 + } + fun isDeletedMessage(id: Long): Boolean = writableDatabase.query( TABLE_NAME, @@ -234,21 +256,6 @@ class MmsDatabase @Inject constructor( } } - private fun rawQuery(where: String, arguments: Array?): Cursor { - val database = readableDatabase - return database.rawQuery( - "SELECT " + MMS_PROJECTION.joinToString(",") + " FROM " + TABLE_NAME + - " LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME + " ON (" + TABLE_NAME + "." + ID + " = " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + ")" + - " LEFT OUTER JOIN " + ReactionDatabase.TABLE_NAME + " ON (" + TABLE_NAME + "." + ID + " = " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 1)" + - " WHERE " + where + " GROUP BY " + TABLE_NAME + "." + ID, arguments - ) - } - - fun getMessage(messageId: Long): Cursor { - val cursor = rawQuery(RAW_ID_WHERE, arrayOf(messageId.toString())) - return cursor - } - override fun getExpiredMessageIDs(nowMills: Long): List { val query = "SELECT " + ID + " FROM " + TABLE_NAME + " WHERE " + EXPIRES_IN + " > 0 AND " + EXPIRE_STARTED + " > 0 AND " + EXPIRE_STARTED + " + " + EXPIRES_IN + " <= ?" @@ -414,151 +421,6 @@ class MmsDatabase @Inject constructor( return result } - @Throws(MmsException::class, NoSuchMessageException::class) - fun getOutgoingMessage(messageId: Long): OutgoingMediaMessage { - var cursor: Cursor? = null - try { - cursor = rawQuery(RAW_ID_WHERE, arrayOf(messageId.toString())) - if (cursor.moveToNext()) { - val associatedAttachments = attachmentDatabase.getAttachmentsForMessage(messageId) - val outboxType = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX)) - val body = cursor.getString(cursor.getColumnIndexOrThrow(BODY)) - val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT)) - val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID)) - val expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN)) - val expireStartedAt = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRE_STARTED)) - val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)) - val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)) - val distributionType = threadDatabase.getDistributionType(threadId) - val mismatchDocument = cursor.getString( - cursor.getColumnIndexOrThrow( - MISMATCHED_IDENTITIES - ) - ) - val networkDocument = cursor.getString( - cursor.getColumnIndexOrThrow( - NETWORK_FAILURE - ) - ) - val quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID)) - val quoteAuthor = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR)) - val quoteText = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_BODY)) // TODO: this should be the referenced quote - val quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE_MISSING)) == 1 - val quoteAttachments = associatedAttachments - .filter { obj: DatabaseAttachment -> obj.isQuote } - val contacts = getSharedContacts(cursor, associatedAttachments) - val contactAttachments: Set = - contacts.mapNotNull { obj: Contact -> obj.avatarAttachment }.toSet() - val previews = getLinkPreviews(cursor, associatedAttachments) - val previewAttachments = - previews.filter { lp: LinkPreview -> lp.getThumbnail().isPresent } - .map { lp: LinkPreview -> lp.getThumbnail().get() } - val attachments = associatedAttachments - .asSequence() - .filterNot { obj: DatabaseAttachment -> obj.isQuote || contactAttachments.contains(obj) || previewAttachments.contains(obj) } - .toList() - var networkFailures: List? = LinkedList() - var mismatches: List? = LinkedList() - var quote: QuoteModel? = null - if (quoteId > 0 && (!quoteText.isNullOrEmpty() || quoteAttachments.isNotEmpty())) { - quote = QuoteModel( - quoteId, - fromSerialized(quoteAuthor), - quoteText, // TODO: refactor this to use referenced quote - quoteMissing, - quoteAttachments - ) - } - if (!mismatchDocument.isNullOrEmpty()) { - try { - mismatches = JsonUtil.fromJson( - mismatchDocument, - IdentityKeyMismatchList::class.java - ).list - } catch (e: IOException) { - Log.w(TAG, e) - } - } - if (!networkDocument.isNullOrEmpty()) { - try { - networkFailures = - JsonUtil.fromJson(networkDocument, NetworkFailureList::class.java).list - } catch (e: IOException) { - Log.w(TAG, e) - } - } - - val messageContentJson = cursor.getString(cursor.getColumnIndexOrThrow(MESSAGE_CONTENT)) - - val messageContent = runCatching { - json.decodeFromString(messageContentJson) - }.onFailure { - Log.w(TAG, "Failed to decode message content for message ID $messageId", it) - }.getOrNull() - - val message = OutgoingMediaMessage( - fromSerialized(address), - body, - attachments, - timestamp, - subscriptionId, - expiresIn, - expireStartedAt, - distributionType, - quote, - contacts, - previews, - networkFailures!!, - mismatches!!, - messageContent, - ) - return if (MmsSmsColumns.Types.isSecureType(outboxType)) { - OutgoingSecureMediaMessage(message) - } else message - } - throw NoSuchMessageException("No record found for id: $messageId") - } finally { - cursor?.close() - } - } - - private fun getSharedContacts( - cursor: Cursor, - attachments: List - ): List { - val serializedContacts = cursor.getString(cursor.getColumnIndexOrThrow(SHARED_CONTACTS)) - if (serializedContacts.isNullOrEmpty()) { - return emptyList() - } - val attachmentIdMap: MutableMap = HashMap() - for (attachment in attachments) { - attachmentIdMap[attachment.attachmentId] = attachment - } - try { - val contacts: MutableList = LinkedList() - val jsonContacts = JSONArray(serializedContacts) - for (i in 0 until jsonContacts.length()) { - val contact = Contact.deserialize(jsonContacts.getJSONObject(i).toString()) - if (contact.avatar != null && contact.avatar!!.attachmentId != null) { - val attachment = attachmentIdMap[contact.avatar!!.attachmentId] - val updatedAvatar = Contact.Avatar( - contact.avatar!!.attachmentId, - attachment, - contact.avatar!!.isProfile - ) - contacts.add(Contact(contact, updatedAvatar)) - } else { - contacts.add(contact) - } - } - return contacts - } catch (e: JSONException) { - Log.w(TAG, "Failed to parse shared contacts.", e) - } catch (e: IOException) { - Log.w(TAG, "Failed to parse shared contacts.", e) - } - return emptyList() - } private fun getLinkPreviews( cursor: Cursor, @@ -598,34 +460,30 @@ class MmsDatabase @Inject constructor( @Throws(MmsException::class) private fun insertMessageInbox( retrieved: IncomingMediaMessage, - contentLocation: String, - threadId: Long, mailbox: Long, - serverTimestamp: Long, + threadId: Long, + mailbox: Long, serverTimestamp: Long, runThreadUpdate: Boolean ): Optional { if (threadId < 0 ) throw MmsException("No thread ID supplied!") - if (retrieved.messageContent is DisappearingMessageUpdate) deleteExpirationTimerMessages(threadId, false.takeUnless { retrieved.groupId != null }) + if (retrieved.messageContent is DisappearingMessageUpdate) + deleteExpirationTimerMessages(threadId, false.takeUnless { retrieved.group != null }) val contentValues = ContentValues() contentValues.put(DATE_SENT, retrieved.sentTimeMillis) contentValues.put(ADDRESS, retrieved.from.toString()) contentValues.put(MESSAGE_BOX, mailbox) contentValues.put(THREAD_ID, threadId) - contentValues.put(CONTENT_LOCATION, contentLocation) contentValues.put(STATUS, Status.DOWNLOAD_INITIALIZED) + contentValues.put(PRO_MESSAGE_FEATURES, retrieved.proFeatures.toProMessageBitSetValue()) + contentValues.put(PRO_PROFILE_FEATURES, retrieved.proFeatures.toProProfileBitSetValue()) // In open groups messages should be sorted by their server timestamp var receivedTimestamp = serverTimestamp if (serverTimestamp == 0L) { receivedTimestamp = retrieved.sentTimeMillis } - contentValues.put( - DATE_RECEIVED, - receivedTimestamp - ) // Loki - This is important due to how we handle GIFs - contentValues.put(PART_COUNT, retrieved.attachments.size) - contentValues.put(SUBSCRIPTION_ID, retrieved.subscriptionId) + contentValues.put(DATE_RECEIVED, receivedTimestamp) // Loki - This is important due to how we handle GIFs contentValues.put(EXPIRES_IN, retrieved.expiresIn) contentValues.put(EXPIRE_STARTED, retrieved.expireStartedAt) - contentValues.put(HAS_MENTION, retrieved.hasMention()) + contentValues.put(HAS_MENTION, retrieved.hasMention) contentValues.put(MESSAGE_REQUEST_RESPONSE, retrieved.isMessageRequestResponse) if (!contentValues.containsKey(DATE_SENT)) { contentValues.put(DATE_SENT, contentValues.getAsLong(DATE_RECEIVED)) @@ -637,7 +495,7 @@ class MmsDatabase @Inject constructor( contentValues.put(QUOTE_MISSING, if (retrieved.quote.missing) 1 else 0) quoteAttachments = retrieved.quote.attachments } - if (retrieved.isPushMessage && isDuplicate(retrieved, threadId) || + if (isDuplicate(retrieved, threadId) || retrieved.isMessageRequestResponse && isDuplicateMessageRequestResponse( retrieved, threadId @@ -651,7 +509,6 @@ class MmsDatabase @Inject constructor( messageContent = retrieved.messageContent, attachments = retrieved.attachments, quoteAttachments = quoteAttachments!!, - sharedContacts = retrieved.sharedContacts, linkPreviews = retrieved.linkPreviews, contentValues = contentValues, ) @@ -693,27 +550,21 @@ class MmsDatabase @Inject constructor( serverTimestamp: Long = 0, runThreadUpdate: Boolean ): Optional { - var type = MmsSmsColumns.Types.BASE_INBOX_TYPE or MmsSmsColumns.Types.SECURE_MESSAGE_BIT - if (retrieved.isPushMessage) { - type = type or MmsSmsColumns.Types.PUSH_MESSAGE_BIT - } - if (retrieved.isScreenshotDataExtraction) { - type = type or MmsSmsColumns.Types.SCREENSHOT_EXTRACTION_BIT - } + var type = MmsSmsColumns.Types.BASE_INBOX_TYPE or MmsSmsColumns.Types.SECURE_MESSAGE_BIT or MmsSmsColumns.Types.PUSH_MESSAGE_BIT if (retrieved.isMediaSavedDataExtraction) { type = type or MmsSmsColumns.Types.MEDIA_SAVED_EXTRACTION_BIT } if (retrieved.isMessageRequestResponse) { type = type or MmsSmsColumns.Types.MESSAGE_REQUEST_RESPONSE_BIT } - return insertMessageInbox(retrieved, "", threadId, type, serverTimestamp, runThreadUpdate) + return insertMessageInbox(retrieved, threadId, type, serverTimestamp, runThreadUpdate) } - @JvmOverloads @Throws(MmsException::class) fun insertMessageOutbox( message: OutgoingMediaMessage, - threadId: Long, forceSms: Boolean, + threadId: Long, + forceSms: Boolean, serverTimestamp: Long = 0, runThreadUpdate: Boolean ): Long { @@ -721,8 +572,8 @@ class MmsDatabase @Inject constructor( if (message.isSecure) type = type or (MmsSmsColumns.Types.SECURE_MESSAGE_BIT or MmsSmsColumns.Types.PUSH_MESSAGE_BIT) if (forceSms) type = type or MmsSmsColumns.Types.MESSAGE_FORCE_SMS_BIT - if (message.isGroup && message is OutgoingGroupMediaMessage) { - if (message.isUpdateMessage) type = type or MmsSmsColumns.Types.GROUP_UPDATE_MESSAGE_BIT + if (message.isGroup) { + if (message.isGroupUpdateMessage) type = type or MmsSmsColumns.Types.GROUP_UPDATE_MESSAGE_BIT } val earlyDeliveryReceipts = earlyDeliveryReceiptCache.remove(message.sentTimeMillis) val earlyReadReceipts = earlyReadReceiptCache.remove(message.sentTimeMillis) @@ -737,10 +588,11 @@ class MmsDatabase @Inject constructor( receivedTimestamp = SnodeAPI.nowWithOffset } contentValues.put(DATE_RECEIVED, receivedTimestamp) - contentValues.put(SUBSCRIPTION_ID, message.subscriptionId) - contentValues.put(EXPIRES_IN, message.expiresIn) - contentValues.put(EXPIRE_STARTED, message.expireStartedAt) + contentValues.put(EXPIRES_IN, message.expiresInMillis) + contentValues.put(EXPIRE_STARTED, message.expireStartedAtMillis) contentValues.put(ADDRESS, message.recipient.toString()) + contentValues.put(PRO_PROFILE_FEATURES, message.proFeatures.toProProfileBitSetValue()) + contentValues.put(PRO_MESSAGE_FEATURES, message.proFeatures.toProMessageBitSetValue()) contentValues.put( DELIVERY_RECEIPT_COUNT, Stream.of(earlyDeliveryReceipts.values).mapToLong { obj: Long -> obj } @@ -751,10 +603,10 @@ class MmsDatabase @Inject constructor( .sum()) val quoteAttachments: MutableList = LinkedList() if (message.outgoingQuote != null) { - contentValues.put(QUOTE_ID, message.outgoingQuote!!.id) - contentValues.put(QUOTE_AUTHOR, message.outgoingQuote!!.author.toString()) - contentValues.put(QUOTE_MISSING, if (message.outgoingQuote!!.missing) 1 else 0) - quoteAttachments.addAll(message.outgoingQuote!!.attachments!!) + contentValues.put(QUOTE_ID, message.outgoingQuote.id) + contentValues.put(QUOTE_AUTHOR, message.outgoingQuote.author.toString()) + contentValues.put(QUOTE_MISSING, if (message.outgoingQuote.missing) 1 else 0) + quoteAttachments.addAll(message.outgoingQuote.attachments!!) } if (isDuplicate(message, threadId)) { Log.w(TAG, "Ignoring duplicate media message (" + message.sentTimeMillis + ")") @@ -765,7 +617,6 @@ class MmsDatabase @Inject constructor( messageContent = message.messageContent, attachments = message.attachments, quoteAttachments = quoteAttachments, - sharedContacts = message.sharedContacts, linkPreviews = message.linkPreviews, contentValues = contentValues, ) @@ -807,7 +658,6 @@ class MmsDatabase @Inject constructor( messageContent: MessageContent?, attachments: List, quoteAttachments: List, - sharedContacts: List, linkPreviews: List, contentValues: ContentValues, ): Long { @@ -816,17 +666,12 @@ class MmsDatabase @Inject constructor( val allAttachments: MutableList = LinkedList() val thumbnailJobs: MutableList = ArrayList() // Collector for thumbnail jobs - val contactAttachments = - Stream.of(sharedContacts).map { obj: Contact -> obj.avatarAttachment } - .filter { a: Attachment? -> a != null } - .toList() val previewAttachments = Stream.of(linkPreviews).filter { lp: LinkPreview -> lp.getThumbnail().isPresent } .map { lp: LinkPreview -> lp.getThumbnail().get() } .toList() allAttachments.addAll(attachments) - allAttachments.addAll(contactAttachments) allAttachments.addAll(previewAttachments) contentValues.put(BODY, body) @@ -845,25 +690,8 @@ class MmsDatabase @Inject constructor( thumbnailJobs // This will collect all attachment IDs that need thumbnails ) - val serializedContacts = - getSerializedSharedContacts(insertedAttachments, sharedContacts) val serializedPreviews = getSerializedLinkPreviews(insertedAttachments, linkPreviews) - if (!serializedContacts.isNullOrEmpty()) { - val contactValues = ContentValues() - contactValues.put(SHARED_CONTACTS, serializedContacts) - val database = readableDatabase - val rows = database.update( - TABLE_NAME, - contactValues, - "$ID = ?", - arrayOf(messageId.toString()) - ) - if (rows <= 0) { - Log.w(TAG, "Failed to update message with shared contact data.") - } - } - if (!serializedPreviews.isNullOrEmpty()) { val contactValues = ContentValues() contactValues.put(LINK_PREVIEWS, serializedPreviews) @@ -901,7 +729,7 @@ class MmsDatabase @Inject constructor( val deletedMessageIDs: MutableList val deletedMessagesThreadIDs = hashSetOf() - writableDatabase.rawQuery( + writableDatabase.rawQuery( "DELETE FROM $TABLE_NAME WHERE $where RETURNING $ID, $THREAD_ID", *whereArgs ).use { cursor -> @@ -957,13 +785,6 @@ class MmsDatabase @Inject constructor( db.update(SmsDatabase.TABLE_NAME, contentValues, "$THREAD_ID = ?", arrayOf("$fromId")) } - @Throws(NoSuchMessageException::class) - override fun getMessageRecord(messageId: Long): MessageRecord { - rawQuery(RAW_ID_WHERE, arrayOf("$messageId")).use { cursor -> - return Reader(cursor).next ?: throw NoSuchMessageException("No message for ID: $messageId") - } - } - fun deleteThread(threadId: Long, updateThread: Boolean) { deleteThreads(listOf(threadId), updateThread) } @@ -992,36 +813,6 @@ class MmsDatabase @Inject constructor( ) } - private fun getSerializedSharedContacts( - insertedAttachmentIds: Map, - contacts: List - ): String? { - if (contacts.isEmpty()) return null - val sharedContactJson = JSONArray() - for (contact in contacts) { - try { - var attachmentId: AttachmentId? = null - if (contact!!.avatarAttachment != null) { - attachmentId = insertedAttachmentIds[contact.avatarAttachment] - } - val updatedAvatar = Contact.Avatar( - attachmentId, - contact.avatarAttachment, - contact.avatar != null && contact.avatar!! - .isProfile - ) - val updatedContact = Contact( - contact, updatedAvatar - ) - sharedContactJson.put(JSONObject(updatedContact.serialize())) - } catch (e: JSONException) { - Log.w(TAG, "Failed to serialize shared contact. Skipping it.", e) - } catch (e: IOException) { - Log.w(TAG, "Failed to serialize shared contact. Skipping it.", e) - } - } - return sharedContactJson.toString() - } private fun getSerializedLinkPreviews( insertedAttachmentIds: Map, @@ -1204,44 +995,38 @@ class MmsDatabase @Inject constructor( return getMediaMmsMessageRecord(cursor!!, getQuote) } - private fun getMediaMmsMessageRecord(cursor: Cursor, getQuote: Boolean): MediaMmsMessageRecord { + private fun getMediaMmsMessageRecord(cursor: Cursor, getQuote: Boolean): MmsMessageRecord { val id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)) val dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT)) val dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_RECEIVED)) val box = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX)) val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)) val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)) - val addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(ADDRESS_DEVICE_ID)) val deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(DELIVERY_RECEIPT_COUNT)) var readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(READ_RECEIPT_COUNT)) val body = cursor.getString(cursor.getColumnIndexOrThrow(BODY)) - val partCount = cursor.getInt(cursor.getColumnIndexOrThrow(PART_COUNT)) - val mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(MISMATCHED_IDENTITIES)) - val networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(NETWORK_FAILURE)) - val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID)) val expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN)) val expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRE_STARTED)) val hasMention = cursor.getInt(cursor.getColumnIndexOrThrow(HAS_MENTION)) == 1 val messageContentJson = cursor.getString(cursor.getColumnIndexOrThrow(MESSAGE_CONTENT)) + val proFeatures = buildSet { + cursor.getLong(cursor.getColumnIndexOrThrow(PRO_MESSAGE_FEATURES)).toProMessageFeatures(this) + cursor.getLong(cursor.getColumnIndexOrThrow(PRO_PROFILE_FEATURES)).toProProfileFeatures(this) + } + if (!isReadReceiptsEnabled(context)) { readReceiptCount = 0 } val recipient = getRecipientFor(address) - val mismatches = getMismatchedIdentities(mismatchDocument) - val networkFailures = getFailures(networkDocument) val attachments = attachmentDatabase.getAttachment( cursor ) - val contacts: List = getSharedContacts(cursor, attachments) - val contactAttachments: Set = - contacts.mapNotNull { it?.avatarAttachment }.toSet() val previews: List = getLinkPreviews(cursor, attachments) val previewAttachments: Set = previews.mapNotNull { it?.getThumbnail()?.orNull() }.toSet() val slideDeck = getSlideDeck( attachments - .filterNot { o: DatabaseAttachment? -> o in contactAttachments } .filterNot { o: DatabaseAttachment? -> o in previewAttachments } ) val quote = if (getQuote) getQuote(cursor) else null @@ -1254,12 +1039,25 @@ class MmsDatabase @Inject constructor( }.getOrNull() return MediaMmsMessageRecord( - id, recipient, recipient, - addressDeviceId, dateSent, dateReceived, deliveryReceiptCount, - threadId, body, slideDeck!!, partCount, box, mismatches, - networkFailures, subscriptionId, expiresIn, expireStarted, - readReceiptCount, quote, contacts, previews, reactions, hasMention, - messageContent + /* id = */ id, + /* conversationRecipient = */ recipient, + /* individualRecipient = */ recipient, + /* dateSent = */ dateSent, + /* dateReceived = */ dateReceived, + /* deliveryReceiptCount = */ deliveryReceiptCount, + /* threadId = */ threadId, + /* body = */ body, + /* slideDeck = */ slideDeck!!, + /* mailbox = */ box, + /* expiresIn = */ expiresIn, + /* expireStarted = */ expireStarted, + /* readReceiptCount = */ readReceiptCount, + /* quote = */ quote, + /* linkPreviews = */ previews, + /* reactions = */ reactions, + /* hasMention = */ hasMention, + /* messageContent = */ messageContent, + /* proFeatures = */ proFeatures ) } @@ -1267,28 +1065,6 @@ class MmsDatabase @Inject constructor( return recipientRepository.getRecipientSync(serialized.toAddress()) } - private fun getMismatchedIdentities(document: String?): List? { - if (!document.isNullOrEmpty()) { - try { - return JsonUtil.fromJson(document, IdentityKeyMismatchList::class.java).list - } catch (e: IOException) { - Log.w(TAG, e) - } - } - return LinkedList() - } - - private fun getFailures(document: String?): List? { - if (!document.isNullOrEmpty()) { - try { - return JsonUtil.fromJson(document, NetworkFailureList::class.java).list - } catch (ioe: IOException) { - Log.w(TAG, ioe) - } - } - return LinkedList() - } - private fun getSlideDeck(attachments: List): SlideDeck? { val messageAttachments: List? = Stream.of(attachments) .filterNot { obj: DatabaseAttachment? -> obj!!.isQuote } @@ -1305,12 +1081,12 @@ class MmsDatabase @Inject constructor( val quoteText = retrievedQuote?.body val quoteMissing = retrievedQuote == null val quoteDeck = ( - (retrievedQuote as? MmsMessageRecord)?.slideDeck ?: - Stream.of(attachmentDatabase.getAttachment(cursor)) - .filter { obj: DatabaseAttachment? -> obj!!.isQuote } - .toList() - .let { SlideDeck(context, it) } - ) + (retrievedQuote as? MmsMessageRecord)?.slideDeck ?: + Stream.of(attachmentDatabase.getAttachment(cursor)) + .filter { obj: DatabaseAttachment? -> obj!!.isQuote } + .toList() + .let { SlideDeck(context, it) } + ) return Quote( quoteId, recipientRepository.getRecipientSync(quoteAuthor.toAddress()), @@ -1331,6 +1107,7 @@ class MmsDatabase @Inject constructor( const val DATE_SENT: String = "date" const val DATE_RECEIVED: String = "date_received" const val MESSAGE_BOX: String = "msg_box" + @Deprecated("No longer used.") const val CONTENT_LOCATION: String = "ct_l" const val EXPIRY: String = "exp" @@ -1338,14 +1115,18 @@ class MmsDatabase @Inject constructor( const val MESSAGE_TYPE: String = "m_type" const val MESSAGE_SIZE: String = "m_size" const val STATUS: String = "st" + @Deprecated("No longer used.") const val TRANSACTION_ID: String = "tr_id" + @Deprecated("No longer used.") const val PART_COUNT: String = "part_count" + @Deprecated("No longer used.") const val NETWORK_FAILURE: String = "network_failures" const val QUOTE_ID: String = "quote_id" const val QUOTE_AUTHOR: String = "quote_author" const val QUOTE_BODY: String = "quote_body" const val QUOTE_ATTACHMENT: String = "quote_attachment" const val QUOTE_MISSING: String = "quote_missing" + @Deprecated("No longer used.") const val SHARED_CONTACTS: String = "shared_contacts" const val LINK_PREVIEWS: String = "previews" @@ -1456,78 +1237,6 @@ class MmsDatabase @Inject constructor( const val ADD_LAST_MESSAGE_INDEX: String = "CREATE INDEX mms_thread_id_date_sent_index ON $TABLE_NAME ($THREAD_ID, $DATE_SENT)" - private val MMS_PROJECTION: Array = arrayOf( - "$TABLE_NAME.$ID AS $ID", - THREAD_ID, - MESSAGE_CONTENT, - "$DATE_SENT AS $NORMALIZED_DATE_SENT", - "$DATE_RECEIVED AS $NORMALIZED_DATE_RECEIVED", - MESSAGE_BOX, - READ, - CONTENT_LOCATION, - EXPIRY, - MESSAGE_TYPE, - MESSAGE_SIZE, - STATUS, - TRANSACTION_ID, - BODY, - PART_COUNT, - ADDRESS, - ADDRESS_DEVICE_ID, - DELIVERY_RECEIPT_COUNT, - READ_RECEIPT_COUNT, - MISMATCHED_IDENTITIES, - NETWORK_FAILURE, - SUBSCRIPTION_ID, - EXPIRES_IN, - EXPIRE_STARTED, - NOTIFIED, - QUOTE_ID, - QUOTE_AUTHOR, - QUOTE_BODY, - QUOTE_ATTACHMENT, - QUOTE_MISSING, - SHARED_CONTACTS, - LINK_PREVIEWS, - HAS_MENTION, - "json_group_array(json_object(" + - "'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " + - "'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " + - "'" + AttachmentDatabase.MMS_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + ", " + - "'" + AttachmentDatabase.SIZE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.SIZE + ", " + - "'" + AttachmentDatabase.FILE_NAME + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FILE_NAME + ", " + - "'" + AttachmentDatabase.DATA + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DATA + ", " + - "'" + AttachmentDatabase.THUMBNAIL + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.THUMBNAIL + ", " + - "'" + AttachmentDatabase.CONTENT_TYPE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_TYPE + ", " + - "'" + AttachmentDatabase.CONTENT_LOCATION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_LOCATION + ", " + - "'" + AttachmentDatabase.FAST_PREFLIGHT_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FAST_PREFLIGHT_ID + "," + - "'" + AttachmentDatabase.VOICE_NOTE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.VOICE_NOTE + "," + - "'" + AttachmentDatabase.WIDTH + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.WIDTH + "," + - "'" + AttachmentDatabase.HEIGHT + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.HEIGHT + "," + - "'" + AttachmentDatabase.QUOTE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.QUOTE + ", " + - "'" + AttachmentDatabase.CONTENT_DISPOSITION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_DISPOSITION + ", " + - "'" + AttachmentDatabase.NAME + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.NAME + ", " + - "'" + AttachmentDatabase.TRANSFER_STATE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFER_STATE + ", " + - "'" + AttachmentDatabase.CAPTION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CAPTION + ", " + - "'" + AttachmentDatabase.STICKER_PACK_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_ID + ", " + - "'" + AttachmentDatabase.STICKER_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " + - "'" + AttachmentDatabase.AUDIO_DURATION + "', ifnull(" + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.AUDIO_DURATION + ", -1), " + - "'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + - ")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS, - "json_group_array(json_object(" + - "'" + ReactionDatabase.ROW_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.ROW_ID + ", " + - "'" + ReactionDatabase.MESSAGE_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + ", " + - "'" + ReactionDatabase.IS_MMS + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + ", " + - "'" + ReactionDatabase.AUTHOR_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.AUTHOR_ID + ", " + - "'" + ReactionDatabase.EMOJI + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.EMOJI + ", " + - "'" + ReactionDatabase.SERVER_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.SERVER_ID + ", " + - "'" + ReactionDatabase.COUNT + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.COUNT + ", " + - "'" + ReactionDatabase.SORT_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.SORT_ID + ", " + - "'" + ReactionDatabase.DATE_SENT + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_SENT + ", " + - "'" + ReactionDatabase.DATE_RECEIVED + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_RECEIVED + - ")) AS " + ReactionDatabase.REACTION_JSON_ALIAS - ) - private const val RAW_ID_WHERE: String = "$TABLE_NAME._id = ?" const val CREATE_MESSAGE_REQUEST_RESPONSE_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $MESSAGE_REQUEST_RESPONSE INTEGER DEFAULT 0;" const val CREATE_REACTIONS_UNREAD_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_UNREAD INTEGER DEFAULT 0;" const val CREATE_REACTIONS_LAST_SEEN_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_LAST_SEEN INTEGER DEFAULT 0;" @@ -1548,5 +1257,10 @@ class MmsDatabase @Inject constructor( "INSERT INTO $TABLE_NAME ($COMMA_SEPARATED_COLUMNS) SELECT $COMMA_SEPARATED_COLUMNS FROM $TEMP_TABLE_NAME", "DROP TABLE $TEMP_TABLE_NAME" ) + + fun addProFeatureColumns(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN $PRO_PROFILE_FEATURES INTEGER NOT NULL DEFAULT 0") + db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN $PRO_MESSAGE_FEATURES INTEGER NOT NULL DEFAULT 0") + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java index 35da0238df..5decc3301d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java @@ -15,11 +15,14 @@ public interface MmsSmsColumns { // It is NOT the address of the sender of any given message! public static final String ADDRESS = "address"; + @Deprecated(forRemoval = true) public static final String ADDRESS_DEVICE_ID = "address_device_id"; public static final String DELIVERY_RECEIPT_COUNT = "delivery_receipt_count"; public static final String READ_RECEIPT_COUNT = "read_receipt_count"; + @Deprecated(forRemoval = true) public static final String MISMATCHED_IDENTITIES = "mismatched_identities"; public static final String UNIQUE_ROW_ID = "unique_row_id"; + @Deprecated(forRemoval = true) public static final String SUBSCRIPTION_ID = "subscription_id"; public static final String EXPIRES_IN = "expires_in"; public static final String EXPIRE_STARTED = "expire_started"; @@ -40,6 +43,9 @@ public interface MmsSmsColumns { public static final String SERVER_HASH = "server_hash"; + public static final String PRO_MESSAGE_FEATURES = "pro_message_features"; + public static final String PRO_PROFILE_FEATURES = "pro_profile_features"; + public static class Types { protected static final long TOTAL_MASK = 0xFFFFFFFF; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index 038c7fc6f4..1aa7363678 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -26,23 +26,20 @@ import android.content.Context; import android.database.Cursor; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - import net.zetetic.database.sqlcipher.SQLiteDatabase; -import org.jetbrains.annotations.NotNull; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import org.session.libsession.messaging.utilities.UpdateMessageData; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.GroupUtil; -import org.session.libsession.utilities.Util; import org.session.libsignal.utilities.AccountId; import org.session.libsignal.utilities.Log; +import org.thoughtcrime.securesms.auth.LoginStateRepository; import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import java.io.Closeable; import java.util.ArrayList; @@ -50,11 +47,18 @@ import java.util.List; import java.util.Set; +import javax.inject.Inject; import javax.inject.Provider; +import javax.inject.Singleton; +import dagger.Lazy; +import dagger.hilt.android.qualifiers.ApplicationContext; import kotlin.Pair; import kotlin.Triple; +import network.loki.messenger.libsession_util.protocol.ProFeature; +import network.loki.messenger.libsession_util.protocol.ProMessageFeature; +@Singleton public class MmsSmsDatabase extends Database { @SuppressWarnings("unused") @@ -64,63 +68,49 @@ public class MmsSmsDatabase extends Database { public static final String MMS_TRANSPORT = "mms"; public static final String SMS_TRANSPORT = "sms"; - private static final String[] PROJECTION = {MmsSmsColumns.ID, MmsSmsColumns.UNIQUE_ROW_ID, - SmsDatabase.BODY, SmsDatabase.TYPE, MmsSmsColumns.MESSAGE_CONTENT, - MmsSmsColumns.THREAD_ID, - SmsDatabase.ADDRESS, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, - MmsSmsColumns.NORMALIZED_DATE_SENT, - MmsSmsColumns.NORMALIZED_DATE_RECEIVED, - MmsDatabase.MESSAGE_TYPE, MmsDatabase.MESSAGE_BOX, - SmsDatabase.STATUS, - MmsDatabase.PART_COUNT, - MmsDatabase.CONTENT_LOCATION, MmsDatabase.TRANSACTION_ID, - MmsDatabase.MESSAGE_SIZE, MmsDatabase.EXPIRY, - MmsDatabase.STATUS, - MmsSmsColumns.DELIVERY_RECEIPT_COUNT, - MmsSmsColumns.READ_RECEIPT_COUNT, - MmsSmsColumns.MISMATCHED_IDENTITIES, - MmsDatabase.NETWORK_FAILURE, - MmsSmsColumns.SUBSCRIPTION_ID, - MmsSmsColumns.EXPIRES_IN, - MmsSmsColumns.EXPIRE_STARTED, - NOTIFIED, - TRANSPORT, - AttachmentDatabase.ATTACHMENT_JSON_ALIAS, - MmsDatabase.QUOTE_ID, - MmsDatabase.QUOTE_AUTHOR, - MmsDatabase.QUOTE_BODY, - MmsDatabase.QUOTE_MISSING, - MmsDatabase.QUOTE_ATTACHMENT, - MmsDatabase.SHARED_CONTACTS, - MmsDatabase.LINK_PREVIEWS, - ReactionDatabase.REACTION_JSON_ALIAS, - MmsSmsColumns.HAS_MENTION, - MmsSmsColumns.SERVER_HASH - }; - - public MmsSmsDatabase(Context context, Provider databaseHelper) { + private static final String PROJECTION_ALL = "*"; + + private final LoginStateRepository loginStateRepository; + private final Lazy<@NonNull ThreadDatabase> threadDatabase; + private final Lazy<@NonNull MmsDatabase> mmsDatabase; + private final Lazy<@NonNull SmsDatabase> smsDatabase; + + @Inject + public MmsSmsDatabase(@ApplicationContext Context context, + Provider databaseHelper, + LoginStateRepository loginStateRepository, + Lazy<@NonNull ThreadDatabase> threadDatabase, + Lazy<@NonNull MmsDatabase> mmsDatabase, + Lazy<@NonNull SmsDatabase> smsDatabase) { super(context, databaseHelper); + + this.loginStateRepository = loginStateRepository; + this.threadDatabase = threadDatabase; + this.mmsDatabase = mmsDatabase; + this.smsDatabase = smsDatabase; } public @Nullable MessageRecord getMessageForTimestamp(long threadId, long timestamp) { final String selection = MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp + " AND " + MmsSmsColumns.THREAD_ID + " = " + threadId; - try (Cursor cursor = queryTables(PROJECTION, selection, true, null, null, null)) { + try (Cursor cursor = queryTables(PROJECTION_ALL, selection, true, null, null, null)) { MmsSmsDatabase.Reader reader = readerFor(cursor); return reader.getNext(); } } public @Nullable MessageRecord getMessageById(@NonNull MessageId id) { - if (id.isMms()) { - final MmsDatabase db = DatabaseComponent.get(context).mmsDatabase(); - try (final Cursor cursor = db.getMessage(id.getId())) { - return db.readerFor(cursor, true).getNext(); + String selection = ID + " = " + id.getId() + " AND " + + TRANSPORT + " = '" + (id.isMms() ? MMS_TRANSPORT : SMS_TRANSPORT) + "'"; + try (MmsSmsDatabase.Reader reader = readerFor(queryTables(PROJECTION_ALL, selection, true, null, null, null))) { + final MessageRecord messageRecord; + if ((messageRecord = reader.getNext()) != null) { + return messageRecord; + } } - } else { - return DatabaseComponent.get(context).smsDatabase().getMessageOrNull(id.getId()); - } + + return null; } public @Nullable MessageRecord getMessageFor(long threadId, long timestamp, String serializedAuthor) { @@ -131,11 +121,11 @@ public MmsSmsDatabase(Context context, Provider databaseHel String selection = MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp + " AND " + MmsSmsColumns.THREAD_ID + " = " + threadId; - try (Cursor cursor = queryTables(PROJECTION, selection, true, null, null, null)) { + try (Cursor cursor = queryTables(PROJECTION_ALL, selection, true, null, null, null)) { MmsSmsDatabase.Reader reader = readerFor(cursor, getQuote); MessageRecord messageRecord; - boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor); + boolean isOwnNumber = serializedAuthor.equals(loginStateRepository.getLocalNumber()); while ((messageRecord = reader.getNext()) != null) { if ((isOwnNumber && messageRecord.isOutgoing()) || @@ -154,11 +144,11 @@ public MmsSmsDatabase(Context context, Provider databaseHel */ @Deprecated(forRemoval = true) public @Nullable MessageRecord getMessageByTimestamp(long timestamp, String serializedAuthor, boolean getQuote) { - try (Cursor cursor = queryTables(PROJECTION, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, true, null, null, null)) { + try (Cursor cursor = queryTables(PROJECTION_ALL, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, true, null, null, null)) { MmsSmsDatabase.Reader reader = readerFor(cursor, getQuote); MessageRecord messageRecord; - boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor); + boolean isOwnNumber = serializedAuthor.equals(loginStateRepository.getLocalNumber()); while ((messageRecord = reader.getNext()) != null) { if ((isOwnNumber && messageRecord.isOutgoing()) || @@ -177,7 +167,7 @@ public MessageId getLastSentMessageID(long threadId) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND NOT " + MmsSmsColumns.IS_DELETED; - try (final Cursor cursor = queryTables(PROJECTION, selection, true, null, order, null)) { + try (final Cursor cursor = queryTables(PROJECTION_ALL, selection, true, null, order, null)) { try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { MessageRecord messageRecord; while ((messageRecord = reader.getNext()) != null) { @@ -200,8 +190,7 @@ public Cursor getConversation(long threadId, boolean reverse, long offset, long String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; String limitStr = limit > 0 || offset > 0 ? offset + ", " + limit : null; - Cursor cursor = queryTables(PROJECTION, selection, true, null, order, limitStr); - return cursor; + return queryTables(PROJECTION_ALL, selection, true, null, order, limitStr); } public Cursor getConversation(long threadId, boolean reverse) { @@ -212,11 +201,11 @@ public Cursor getConversationSnippet(long threadId) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; - return queryTables(PROJECTION, selection, true, null, order, null); + return queryTables(PROJECTION_ALL, selection, true, null, order, null); } public List getRecentChatMemberAddresses(long threadId, int limit) { - String[] projection = new String[] { "DISTINCT " + MmsSmsColumns.ADDRESS }; + String projection = "DISTINCT " + MmsSmsColumns.ADDRESS; String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String limitStr = String.valueOf(limit); @@ -258,7 +247,7 @@ public Set getAllMessageRecordsFromSenderInThread(long threadId, Set identifiedMessages = new HashSet(); // Try everything with resources so that they auto-close on end of scope - try (Cursor cursor = queryTables(PROJECTION, selection, true, null, null, null)) { + try (Cursor cursor = queryTables(PROJECTION_ALL, selection, true, null, null, null)) { try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { MessageRecord messageRecord; while ((messageRecord = reader.getNext()) != null) { @@ -274,7 +263,7 @@ public List> getAllMessageRecordsBefore(long threadI List> identifiedMessages = new ArrayList<>(); // Try everything with resources so that they auto-close on end of scope - try (Cursor cursor = queryTables(PROJECTION, selection, true, null, null, null)) { + try (Cursor cursor = queryTables(PROJECTION_ALL, selection, true, null, null, null)) { try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { MessageRecord messageRecord; while ((messageRecord = reader.getNext()) != null) { @@ -293,7 +282,7 @@ public List> getAllMessagesWithHash(long threadId) { String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; List> identifiedMessages = new ArrayList<>(); - try (Cursor cursor = queryTables(PROJECTION, selection, true, null, null, null); + try (Cursor cursor = queryTables(PROJECTION_ALL, selection, true, null, null, null); MmsSmsDatabase.Reader reader = readerFor(cursor)) { MessageRecord record; @@ -317,7 +306,7 @@ public MessageRecord getLastMessage(long threadId, boolean includeReactions, boo String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + "NOT " + MmsSmsColumns.IS_DELETED; - try (Cursor cursor = queryTables(PROJECTION, selection, includeReactions, null, order, "1")) { + try (Cursor cursor = queryTables(PROJECTION_ALL, selection, includeReactions, null, order, "1")) { return readerFor(cursor, getQuote).getNext(); } } @@ -357,7 +346,7 @@ public Cursor getUnreadIncomingForNotifications(int maxRows) { String selection = "(" + READ + " = 0 AND " + NOTIFIED + " = 0 AND NOT (" + outgoing + "))"; String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String limitStr = maxRows > 0 ? String.valueOf(maxRows) : null; - return queryTables(PROJECTION, selection, true, null, order, limitStr); + return queryTables(PROJECTION_ALL, selection, true, null, order, limitStr); } public Cursor getOutgoingWithUnseenReactionsForNotifications(int maxRows) { @@ -372,11 +361,11 @@ public Cursor getOutgoingWithUnseenReactionsForNotifications(int maxRows) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String limitStr = maxRows > 0 ? String.valueOf(maxRows) : null; - return queryTables(PROJECTION, outgoing, true, reactionSelection, order, limitStr); + return queryTables(PROJECTION_ALL, outgoing, true, reactionSelection, order, limitStr); } public Set
getAllReferencedAddresses() { - final String[] projection = new String[] { "DISTINCT " + MmsSmsColumns.ADDRESS }; + final String projection = "DISTINCT " + MmsSmsColumns.ADDRESS; final String selection = MmsSmsColumns.ADDRESS + " IS NOT NULL" + " AND " + MmsSmsColumns.ADDRESS + " != ''"; @@ -409,17 +398,14 @@ private String buildOutgoingTypesList() { public int getUnreadCount(long threadId) { String selection = READ + " = 0 AND " + NOTIFIED + " = 0 AND " + MmsSmsColumns.THREAD_ID + " = " + threadId; - Cursor cursor = queryTables(new String[] { ID }, selection, true, null, null, null); - try { + try (Cursor cursor = queryTables(ID, selection, true, null, null, null)) { return cursor != null ? cursor.getCount() : 0; - } finally { - if (cursor != null) cursor.close(); } } public void deleteGroupInfoMessage(AccountId groupId, Class kind) { - long threadId = DatabaseComponent.get(context).threadDatabase().getThreadIdIfExistsFor(groupId.getHexString()); + long threadId = threadDatabase.get().getThreadIdIfExistsFor(groupId.getHexString()); if (threadId == -1) { Log.d(TAG, "No thread found for group info message deletion"); return; @@ -437,24 +423,36 @@ public void deleteGroupInfoMessage(AccountId groupId, Class timestampAndDirectionForCurrent(@NotNull Cursor cursor) { + @NonNull + public Pair timestampAndDirectionForCurrent(@NonNull Cursor cursor) { int sentColumn = cursor.getColumnIndex(MmsSmsColumns.NORMALIZED_DATE_SENT); String msgType = cursor.getString(cursor.getColumnIndexOrThrow(TRANSPORT)); long sentTime = cursor.getLong(sentColumn); @@ -653,7 +651,7 @@ public Reader(Cursor cursor, boolean getQuote) { private SmsDatabase.Reader getSmsReader() { if (smsReader == null) { - smsReader = DatabaseComponent.get(context).smsDatabase().readerFor(cursor); + smsReader = smsDatabase.get().readerFor(cursor); } return smsReader; @@ -661,7 +659,7 @@ private SmsDatabase.Reader getSmsReader() { private MmsDatabase.Reader getMmsReader() { if (mmsReader == null) { - mmsReader = DatabaseComponent.get(context).mmsDatabase().readerFor(cursor, getQuote); + mmsReader = mmsDatabase.get().readerFor(cursor, getQuote); } return mmsReader; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabaseSQL.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabaseSQL.kt index 59ab96dc09..e4682ee9e6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabaseSQL.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabaseSQL.kt @@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.database.model.MessageId * ```sqlite * SELECT sms_fields, * (query reaction table) AS reactions, + * NULL AS attachments, * (query hash table) AS server_hash * FROM sms * @@ -27,7 +28,7 @@ import org.thoughtcrime.securesms.database.model.MessageId * ``` */ fun buildMmsSmsCombinedQuery( - projections: Array, + projection: String, selection: String?, includeReactions: Boolean, reactionSelection: String?, @@ -91,35 +92,28 @@ fun buildMmsSmsCombinedQuery( ${MmsSmsColumns.THREAD_ID}, ${SmsDatabase.TYPE}, ${SmsDatabase.ADDRESS}, - ${SmsDatabase.ADDRESS_DEVICE_ID}, - ${SmsDatabase.SUBJECT}, NULL AS ${MmsDatabase.MESSAGE_TYPE}, NULL AS ${MmsDatabase.MESSAGE_BOX}, ${SmsDatabase.STATUS}, - NULL AS ${MmsDatabase.PART_COUNT}, - NULL AS ${MmsDatabase.CONTENT_LOCATION}, - NULL AS ${MmsDatabase.TRANSACTION_ID}, NULL AS ${MmsDatabase.MESSAGE_SIZE}, NULL AS ${MmsDatabase.EXPIRY}, NULL AS ${MmsDatabase.STATUS}, ${MmsSmsColumns.DELIVERY_RECEIPT_COUNT}, ${MmsSmsColumns.READ_RECEIPT_COUNT}, - ${MmsSmsColumns.MISMATCHED_IDENTITIES}, - ${MmsSmsColumns.SUBSCRIPTION_ID}, ${MmsSmsColumns.EXPIRES_IN}, ${MmsSmsColumns.EXPIRE_STARTED}, ${MmsSmsColumns.NOTIFIED}, - NULL AS ${MmsDatabase.NETWORK_FAILURE}, '${MmsSmsDatabase.SMS_TRANSPORT}' AS ${MmsSmsDatabase.TRANSPORT}, NULL AS ${MmsDatabase.QUOTE_ID}, NULL AS ${MmsDatabase.QUOTE_AUTHOR}, NULL AS ${MmsDatabase.QUOTE_BODY}, NULL AS ${MmsDatabase.QUOTE_MISSING}, NULL AS ${MmsDatabase.QUOTE_ATTACHMENT}, - NULL AS ${MmsDatabase.SHARED_CONTACTS}, NULL AS ${MmsDatabase.LINK_PREVIEWS}, ${MmsSmsColumns.HAS_MENTION}, - ($smsHashQuery) AS ${MmsSmsColumns.SERVER_HASH} + ($smsHashQuery) AS ${MmsSmsColumns.SERVER_HASH}, + ${MmsSmsColumns.PRO_MESSAGE_FEATURES}, + ${MmsSmsColumns.PRO_PROFILE_FEATURES} FROM ${SmsDatabase.TABLE_NAME} $whereStatement """ @@ -189,35 +183,28 @@ fun buildMmsSmsCombinedQuery( ${MmsSmsColumns.THREAD_ID}, NULL AS ${SmsDatabase.TYPE}, ${MmsSmsColumns.ADDRESS}, - ${MmsSmsColumns.ADDRESS_DEVICE_ID}, - NULL AS ${SmsDatabase.SUBJECT}, ${MmsDatabase.MESSAGE_TYPE}, ${MmsDatabase.MESSAGE_BOX}, NULL AS ${SmsDatabase.STATUS}, - ${MmsDatabase.PART_COUNT}, - ${MmsDatabase.CONTENT_LOCATION}, - ${MmsDatabase.TRANSACTION_ID}, ${MmsDatabase.MESSAGE_SIZE}, ${MmsDatabase.EXPIRY}, ${MmsDatabase.STATUS}, ${MmsSmsColumns.DELIVERY_RECEIPT_COUNT}, ${MmsSmsColumns.READ_RECEIPT_COUNT}, - ${MmsSmsColumns.MISMATCHED_IDENTITIES}, - ${MmsSmsColumns.SUBSCRIPTION_ID}, ${MmsSmsColumns.EXPIRES_IN}, ${MmsSmsColumns.EXPIRE_STARTED}, ${MmsSmsColumns.NOTIFIED}, - ${MmsDatabase.NETWORK_FAILURE}, '${MmsSmsDatabase.MMS_TRANSPORT}' AS ${MmsSmsDatabase.TRANSPORT}, ${MmsDatabase.QUOTE_ID}, ${MmsDatabase.QUOTE_AUTHOR}, ${MmsDatabase.QUOTE_BODY}, ${MmsDatabase.QUOTE_MISSING}, ${MmsDatabase.QUOTE_ATTACHMENT}, - ${MmsDatabase.SHARED_CONTACTS}, ${MmsDatabase.LINK_PREVIEWS}, ${MmsSmsColumns.HAS_MENTION}, - ($mmsHashQuery) AS ${MmsSmsColumns.SERVER_HASH} + ($mmsHashQuery) AS ${MmsSmsColumns.SERVER_HASH}, + ${MmsSmsColumns.PRO_MESSAGE_FEATURES}, + ${MmsSmsColumns.PRO_PROFILE_FEATURES} FROM ${MmsDatabase.TABLE_NAME} $whereStatement """ @@ -232,7 +219,7 @@ fun buildMmsSmsCombinedQuery( $mmsQuery ) - SELECT ${projections.joinToString(", ")} + SELECT $projection FROM combined $orderStatement $limitStatement diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt index 8b47b7dfb0..9ed01cc896 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt @@ -264,6 +264,30 @@ class ReactionDatabase(context: Context, helper: Provider) ) } + /** + * Update the count for all reactions with the given emoji on the specified message. + * Note this should ONLY be used on community reactions as each reaction record contains the + * same count for that particular emoji/messageId, so it makes sense to update them all at once. + * + * For other type of messages, this will likely result to errors in the reaction counts! + */ + fun updateAllCountFor(messageId: MessageId, emoji: String, countDiff: Int) { + val changed = writableDatabase.compileStatement(""" + UPDATE $TABLE_NAME SET $COUNT = $COUNT + ? + WHERE $MESSAGE_ID = ? AND $IS_MMS = ? AND $EMOJI = ? + """).use { statement -> + statement.bindLong(1, countDiff.toLong()) + statement.bindLong(2, messageId.id) + statement.bindLong(3, if (messageId.mms) 1 else 0) + statement.bindString(4, emoji) + statement.executeUpdateDelete() > 0 + } + + if (changed) { + mutableChangeNotification.tryEmit(messageId) + } + } + private fun deleteReactions(query: String, args: Array) { val updatedMessageIDs = writableDatabase.rawQuery("DELETE FROM $TABLE_NAME WHERE $query RETURNING $MESSAGE_ID, $IS_MMS", *args).use { cursor -> cursor.asSequence() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ReceivedMessageHashDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ReceivedMessageHashDatabase.kt new file mode 100644 index 0000000000..9436481548 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ReceivedMessageHashDatabase.kt @@ -0,0 +1,146 @@ +package org.thoughtcrime.securesms.database + +import android.content.Context +import androidx.collection.LruCache +import androidx.sqlite.db.SupportSQLiteDatabase +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.serialization.json.Json +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +/** + * This database table keeps track of which message hashes we've already received for + * particular senders (swarm public keys) and namespaces. This is used to prevent + * processing the same message multiple times. + * + * To use this class, call [checkOrUpdateDuplicateState] to atomically check if a message hash + * has already been seen, and if not, add it to the database. + */ +@Singleton +class ReceivedMessageHashDatabase @Inject constructor( + @ApplicationContext context: Context, + databaseHelper: Provider, + private val json: Json, +) : Database(context, databaseHelper) { + + private data class CacheKey(val publicKey: String, val namespace: Int, val hash: String) + + private val cache = LruCache(1024) + + fun removeAllByNamespaces(vararg namespaces: Int) { + synchronized(cache) { + cache.evictAll() + } + + //language=roomsql + writableDatabase.rawExecSQL(""" + DELETE FROM received_messages + WHERE namespace IN (SELECT value FROM json_each(?)) + """, json.encodeToString(namespaces)) + } + + fun removeAllByPublicKey(publicKey: String) { + synchronized(cache) { + cache.evictAll() + } + + //language=roomsql + writableDatabase.rawExecSQL(""" + DELETE FROM received_messages + WHERE swarm_pub_key = ? + """, publicKey) + } + + fun removeAll() { + synchronized(cache) { + cache.evictAll() + } + + //language=roomsql + writableDatabase.rawExecSQL("DELETE FROM received_messages WHERE 1") + } + + /** + * Checks if the given [hash] is already present in the database for the given + * [swarmPublicKey] and [namespace]. If not, adds it to the database. + * + * This implementation is atomic. + * + * @return true if the hash was already in the db + */ + fun checkOrUpdateDuplicateState( + swarmPublicKey: String, + namespace: Int, + hash: String + ): Boolean { + val key = CacheKey(swarmPublicKey, namespace, hash) + synchronized(cache) { + if (cache[key] != null) { + return true + } + } + + //language=roomsql + return writableDatabase.compileStatement(""" + INSERT OR IGNORE INTO received_messages (swarm_pub_key, namespace, hash) + VALUES (?, ?, ?) + """).use { stmt -> + stmt.bindString(1, swarmPublicKey) + stmt.bindLong(2, namespace.toLong()) + stmt.bindString(3, hash) + stmt.executeUpdateDelete() == 0 + }.also { + synchronized(cache) { + cache.put(key, Unit) + } + } + } + + companion object { + fun createAndMigrateTable(db: SupportSQLiteDatabase) { + //language=roomsql + db.execSQL(""" + CREATE TABLE IF NOT EXISTS received_messages( + swarm_pub_key TEXT NOT NULL, + namespace INTEGER NOT NULL, + hash TEXT NOT NULL, + PRIMARY KEY (swarm_pub_key, namespace, hash) + ) WITHOUT ROWID; + """) + + //language=roomsql + db.compileStatement(""" + INSERT OR IGNORE INTO received_messages (swarm_pub_key, namespace, hash) + VALUES (?, ?, ?) + """).use { stmt -> + + //language=roomsql + db.query(""" + SELECT public_key, received_message_hash_values, received_message_namespace + FROM session_received_message_hash_values_table + """, arrayOf()).use { cursor -> + while (cursor.moveToNext()) { + val publicKey = cursor.getString(0) + val hashValuesString = cursor.getString(1) + val namespace = cursor.getInt(2) + + val hashValues = hashValuesString.splitToSequence('-') + + for (hash in hashValues) { + stmt.bindString(1, publicKey) + stmt.bindLong(2, namespace.toLong()) + stmt.bindString(3, hash) + stmt.execute() + stmt.clearBindings() + } + } + } + } + + //language=roomsql + db.execSQL("DROP TABLE session_received_message_hash_values_table") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt index d36ad9b6bc..b0846c1d69 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt @@ -1,34 +1,39 @@ package org.thoughtcrime.securesms.database import androidx.collection.LruCache +import dagger.Lazy import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.shareIn -import kotlinx.coroutines.time.delay import kotlinx.coroutines.withContext -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.ReadableGroupInfoConfig +import network.loki.messenger.libsession_util.protocol.ProProfileFeature import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.GroupInfo import org.session.libsession.messaging.open_groups.GroupMemberRole import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.snode.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol @@ -37,7 +42,6 @@ import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.getGroup -import org.session.libsession.utilities.recipients.ProStatus import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientData import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemoteFile @@ -46,10 +50,13 @@ import org.session.libsession.utilities.toGroupString import org.session.libsession.utilities.userConfigsChanged import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.model.NotifyType import org.thoughtcrime.securesms.database.model.RecipientSettings import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.groups.GroupMemberComparator +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.pro.db.ProDatabase import org.thoughtcrime.securesms.util.DateUtils.Companion.secondsToInstant import java.lang.ref.WeakReference import java.time.Duration @@ -70,12 +77,16 @@ import javax.inject.Singleton @Singleton class RecipientRepository @Inject constructor( private val configFactory: ConfigFactoryProtocol, + private val prefs: TextSecurePreferences, private val groupDatabase: GroupDatabase, private val recipientSettingsDatabase: RecipientSettingsDatabase, - private val preferences: TextSecurePreferences, private val blindedIdMappingRepository: BlindMappingRepository, private val communityDatabase: CommunityDatabase, + private val loginStateRepository: LoginStateRepository, @param:ManagerScope private val managerScope: CoroutineScope, + private val proDatabase: ProDatabase, + private val snodeClock: Lazy, + private val proStatusManager: Lazy // Only needed temporary to check post-pro ) { private val recipientFlowCache = LruCache>>(512) @@ -92,19 +103,23 @@ class RecipientRepository @Inject constructor( return newFlow } + @OptIn(ExperimentalCoroutinesApi::class) fun observeSelf(): Flow { - return preferences.watchLocalNumber() - .flatMapLatest { - if (it.isNullOrBlank()) { + return loginStateRepository + .loggedInState + .map { it?.accountId } + .distinctUntilChanged() + .flatMapLatest { accountId -> + if (accountId == null) { emptyFlow() } else { - observeRecipient(it.toAddress()) + observeRecipient(accountId.toAddress()) } } } fun getSelf(): Recipient { - return getRecipientSync(preferences.getLocalNumber()!!.toAddress()) + return getRecipientSync(loginStateRepository.requireLocalAccountId().toAddress()) } // This function creates a flow that emits the recipient information for the given address, @@ -118,11 +133,18 @@ class RecipientRepository @Inject constructor( settingsFetcher = { withContext(Dispatchers.Default) { recipientSettingsDatabase.getSettings(it) } }, - communityFetcher = { withContext(Dispatchers.Default) { communityDatabase.getRoomInfo(it) } } + communityFetcher = { + withContext(Dispatchers.Default) { + communityDatabase.getRoomInfo( + it + ) + } + }, + needFlow = true, ) emit(value) - val evt = changeSource.debounce(200).first() + val evt = changeSource!!.debounce(200).first() Log.d(TAG, "Recipient changed for ${address.debugString}, triggering event: $evt") } @@ -134,75 +156,133 @@ class RecipientRepository @Inject constructor( ) } + /** + * A context object to collect data during the fetchRecipient process. For now we only + * collect [RecipientSettings.ProData], this is needed because some recipient (like groups) have + * multiple sub-recipients, and each of them may have their own pro data. + * We then collect all the pro data and perform a final calculation at the end of [fetchRecipient]. + */ + private class ProDataContext { + var proDataList: MutableList? = null + + fun addProData(proData: RecipientSettings.ProData?) { + if (proData == null) { + return + } + + if (proDataList == null) { + proDataList = mutableListOf() + } + + proDataList!!.add(proData) + } + } + private inline fun fetchRecipient( address: Address, settingsFetcher: (Address) -> RecipientSettings, communityFetcher: (Address.Community) -> OpenGroupApi.RoomInfo?, - ): Pair> { - val recipientData = - address.toBlinded()?.let { blindedIdMappingRepository.findMappings(it).firstOrNull()?.second } - ?.let(this::getDataFromConfig) - ?: getDataFromConfig(address) + needFlow: Boolean, + ): Pair?> { + val now = snodeClock.get().currentTime() + + val proDataContext = if (proStatusManager.get().postProLaunchStatus.value) { + ProDataContext() + } else { + null + } - val changeSource: Flow<*> + // Fetch data from config first, this may contain partial information for some kind of recipient + val configData = getDataFromConfig( + address = address.toBlinded() + ?.let { blindedIdMappingRepository.findMappings(it).firstOrNull()?.second } ?: address, + proDataContext = proDataContext + ) + + val changeSources: MutableList>? val value: Recipient - when (recipientData) { + when (configData) { is RecipientData.Self -> { - value = createLocalRecipient(address, recipientData) - changeSource = merge( - configFactory.userConfigsChanged(onlyConfigTypes = EnumSet.of(UserConfigType.USER_PROFILE)), - TextSecurePreferences.events.filter { it == TextSecurePreferences.SET_FORCE_CURRENT_USER_PRO } - ) + value = createLocalRecipient(address, configData) + changeSources = if (needFlow) { + arrayListOf( + configFactory.userConfigsChanged(onlyConfigTypes = EnumSet.of(UserConfigType.USER_PROFILE)), + TextSecurePreferences.events.filter { + it == TextSecurePreferences.SET_FORCE_CURRENT_USER_PRO + || it == TextSecurePreferences.DEBUG_SUBSCRIPTION_STATUS + }, + ) + } else { + null + } } is RecipientData.BlindedContact -> { - value = Recipient( - address = address, - data = recipientData, - ) + value = Recipient(address = address, data = configData) - changeSource = merge( - configFactory.userConfigsChanged(onlyConfigTypes = EnumSet.of(UserConfigType.CONTACTS)), - TextSecurePreferences.events.filter { it == TextSecurePreferences.SET_FORCE_OTHER_USERS_PRO } - ) + changeSources = if (needFlow) { + arrayListOf( + configFactory.userConfigsChanged(onlyConfigTypes = EnumSet.of(UserConfigType.CONTACTS)), + TextSecurePreferences.events.filter { it == TextSecurePreferences.SET_FORCE_OTHER_USERS_PRO }, + proDatabase.revocationChangeNotification, + ) + } else { + null + } } is RecipientData.Contact -> { value = createContactRecipient( address = address, - basic = recipientData, + configData = configData, fallbackSettings = settingsFetcher(address) ) - changeSource = merge( - configFactory.userConfigsChanged(onlyConfigTypes = EnumSet.of(UserConfigType.CONTACTS)), - recipientSettingsDatabase.changeNotification.filter { it == address }, - TextSecurePreferences.events.filter { it == TextSecurePreferences.SET_FORCE_OTHER_USERS_PRO } - ) + changeSources = if (needFlow) { + arrayListOf( + configFactory.userConfigsChanged(onlyConfigTypes = EnumSet.of(UserConfigType.CONTACTS)), + recipientSettingsDatabase.changeNotification.filter { it == address }, + TextSecurePreferences.events.filter { it == TextSecurePreferences.SET_FORCE_OTHER_USERS_PRO }, + proDatabase.revocationChangeNotification, + ) + } else { + null + } } - is RecipientData.PartialGroup -> { + is RecipientData.Group -> { value = createGroupV2Recipient( address = address, - partial = recipientData, + proDataContext = proDataContext, + configData = configData, settings = settingsFetcher(address), - settingsFetcher = settingsFetcher + settingsFetcher = settingsFetcher, ) - val memberAddresses = recipientData.members.mapTo(hashSetOf()) { it.address } - - changeSource = merge( - configFactory.userConfigsChanged(onlyConfigTypes = EnumSet.of(UserConfigType.USER_GROUPS)), - configFactory.configUpdateNotifications - .filterIsInstance() - .filter { it.groupId.hexString == address.address }, - recipientSettingsDatabase.changeNotification.filter { it == address || memberAddresses.contains(it) }, - TextSecurePreferences.events.filter { it == TextSecurePreferences.SET_FORCE_OTHER_USERS_PRO } - ) + val memberAddresses = configData.members.mapTo(hashSetOf()) { it.address } + + changeSources = if (needFlow) { + arrayListOf( + configFactory.userConfigsChanged(onlyConfigTypes = EnumSet.of(UserConfigType.USER_GROUPS, + UserConfigType.USER_PROFILE)), + configFactory.configUpdateNotifications + .filterIsInstance() + .filter { it.groupId.hexString == address.address }, + recipientSettingsDatabase.changeNotification.filter { + it == address || memberAddresses.contains( + it + ) + }, + TextSecurePreferences.events.filter { it == TextSecurePreferences.SET_FORCE_OTHER_USERS_PRO }, + proDatabase.revocationChangeNotification, + ) + } else { + null + } } - null -> { + else -> { // Given address is not backed by the config system so we'll get them from // local database. // If this is a community inbox, we'll load the underlying blinded recipient settings @@ -226,14 +306,33 @@ class RecipientRepository @Inject constructor( val memberAddresses = group?.members?.toSet().orEmpty() - changeSource = merge( - groupDatabase.updateNotification, - recipientSettingsDatabase.changeNotification.filter { it == address || it in memberAddresses }, - configFactory.userConfigsChanged(onlyConfigTypes = EnumSet.of(UserConfigType.USER_GROUPS)), + changeSources = if (needFlow) { + arrayListOf( + groupDatabase.updateNotification, + recipientSettingsDatabase.changeNotification.filter { it == address || it in memberAddresses }, + configFactory.userConfigsChanged( + onlyConfigTypes = EnumSet.of( + UserConfigType.USER_GROUPS + ) + ), + ) + } else { + null + } + value = group?.let { + createLegacyGroupRecipient( + proDataContext = proDataContext, + address = address, + config = groupConfig, + group = it, + settings = settings, + settingsFetcher = settingsFetcher + ) + } ?: createGenericRecipient( + address = address, + proDataContext = proDataContext, + settings = settings ) - - value = group?.let { createLegacyGroupRecipient(address, groupConfig, it, settings, settingsFetcher) } - ?: createGenericRecipient(address, settings) } is Address.Community -> { @@ -246,20 +345,33 @@ class RecipientRepository @Inject constructor( roomInfo = communityFetcher(address), settings = settings ) - } ?: createGenericRecipient(address, settings) - - changeSource = merge( - recipientSettingsDatabase.changeNotification.filter { it == address }, - communityDatabase.changeNotification.filter { it == address }, - configFactory.userConfigsChanged(onlyConfigTypes = EnumSet.of(UserConfigType.USER_GROUPS)), + } ?: createGenericRecipient( + address = address, + proDataContext = proDataContext, + settings = settings ) + + changeSources = if (needFlow) { + arrayListOf( + recipientSettingsDatabase.changeNotification.filter { it == address }, + communityDatabase.changeNotification.filter { it == address }, + configFactory.userConfigsChanged( + onlyConfigTypes = EnumSet.of( + UserConfigType.USER_GROUPS + ) + ), + ) + } else { + null + } } is Address.Standard -> { // If we are a standard address, last attempt to find the // recipient inside all closed groups' member list // members: - val allGroups = configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() } + val allGroups = + configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() } value = allGroups .asSequence() .mapNotNull { groupInfo -> @@ -268,50 +380,101 @@ class RecipientRepository @Inject constructor( }?.let(RecipientData::GroupMemberInfo) } .firstOrNull() - ?.let { groupMember -> fetchGroupMember(groupMember, settingsFetcher) } - ?: createGenericRecipient(address, settings) - - changeSource = merge( - configFactory.configUpdateNotifications.filterIsInstance() - .filter { it.groupId == address.accountId }, - configFactory.userConfigsChanged(), - recipientSettingsDatabase.changeNotification.filter { it == address }, - TextSecurePreferences.events.filter { it == TextSecurePreferences.SET_FORCE_OTHER_USERS_PRO } - ) + ?.let { groupMember -> + fetchGroupMember( + proDataContext = proDataContext, + member = groupMember, + settingsFetcher = settingsFetcher + ) + } + ?: createGenericRecipient( + address = address, + proDataContext = proDataContext, + settings = settings + ) + + changeSources = if (needFlow) { + arrayListOf( + configFactory.configUpdateNotifications.filterIsInstance() + .filter { it.groupId == address.accountId }, + configFactory.userConfigsChanged(), + recipientSettingsDatabase.changeNotification.filter { it == address }, + TextSecurePreferences.events.filter { it == TextSecurePreferences.SET_FORCE_OTHER_USERS_PRO }, + configFactory.userConfigsChanged(EnumSet.of(UserConfigType.USER_PROFILE)), + ) + } else { + null + } } else -> { - value = createGenericRecipient(address, settings) - changeSource = merge( - recipientSettingsDatabase.changeNotification.filter { it == address }, - TextSecurePreferences.events.filter { it == TextSecurePreferences.SET_FORCE_OTHER_USERS_PRO } + value = createGenericRecipient( + address = address, + proDataContext = proDataContext, + settings = settings ) + + changeSources = if (needFlow) { + arrayListOf( + recipientSettingsDatabase.changeNotification.filter { it == address }, + TextSecurePreferences.events.filter { it == TextSecurePreferences.SET_FORCE_OTHER_USERS_PRO }, + configFactory.userConfigsChanged(EnumSet.of(UserConfigType.USER_PROFILE)), + ) + } else { + null + } } } } } - val updatedChangeSource = (value.proStatus as? ProStatus.Pro) - ?.validUntil - ?.let { validUntil -> - val now = Instant.now() - if (validUntil >= now) { - return@let merge( - changeSource, - flow { - delay(Duration.between(now, validUntil)) - - // Emit anything to trigger a recipient update - emit("ProStatus validity change") - } - ) - } + // Calculate the ProData for this recipient + val proDataList = proDataContext?.proDataList + var proData = if (!proDataList.isNullOrEmpty()) { + proDataList.removeAll { + it.isExpired(now) || proDatabase.isRevoked(it.genIndexHash) + } - changeSource + // The pro data that goes into the recipient, should show the one with the most information + proDataList.firstOrNull { it.showProBadge }?.let { + RecipientData.ProData(it.showProBadge) } - ?: changeSource + } else { + null + } - return value to updatedChangeSource + // Debug overrides from preferences + if (value.isSelf && proData == null && prefs.forceCurrentUserAsPro()) { + proData = RecipientData.ProData(showProBadge = true) + } else if (!value.isSelf + && (value.address is Address.Standard || value.address is Address.Group) + && proData == null + && prefs.forceOtherUsersAsPro()) { + proData = RecipientData.ProData(showProBadge = true) + } + + val updatedValue = if (value.data.proData != proData && proData != null) { + value.copy(data = value.data.setProData(proData)) + } else { + value + } + + if (changeSources != null && !proDataList.isNullOrEmpty()) { + // If we have valid pro data, we need to add a flow to trigger a re-fetch when + // the earliest pro proof expires. + val earliestProExpiry = proDataList.minOf { it.expiry } + + val delayMills = Duration.between(now, earliestProExpiry).toMillis() + // Add a flow that triggers when the pro proof expires + changeSources.add(flowOf("Pro proof expires") + .onStart { delay(delayMills) }) + } + + // Add post-pro status as a change source to ensure recipients are updated after + // post-pro launch flag is toggled. + changeSources?.add(proStatusManager.get().postProLaunchStatus.drop(1)) + + return updatedValue to changeSources?.let { merge(*it.toTypedArray()) } } /** @@ -319,10 +482,11 @@ class RecipientRepository @Inject constructor( * for a group member purpose. */ private inline fun fetchGroupMember( + proDataContext: ProDataContext?, member: RecipientData.GroupMemberInfo, - settingsFetcher: (address: Address) -> RecipientSettings, + settingsFetcher: (address: Address) -> RecipientSettings ): Recipient { - return when (val configData = getDataFromConfig(member.address)) { + return when (val configData = getDataFromConfig(member.address, proDataContext)) { is RecipientData.Self -> { createLocalRecipient(member.address, configData) } @@ -330,7 +494,7 @@ class RecipientRepository @Inject constructor( is RecipientData.Contact -> { createContactRecipient( address = member.address, - basic = configData, + configData = configData, fallbackSettings = settingsFetcher(member.address) ) } @@ -340,8 +504,9 @@ class RecipientRepository @Inject constructor( // with the settings fetched from the database. createGenericRecipient( address = member.address, + proDataContext = proDataContext, settings = settingsFetcher(member.address), - groupMemberInfo = member, + groupMemberInfo = member ) } } @@ -349,9 +514,10 @@ class RecipientRepository @Inject constructor( private inline fun fetchLegacyGroupMember( address: Address.Standard, + proDataContext: ProDataContext?, settingsFetcher: (address: Address) -> RecipientSettings, ): Recipient { - return when (val configData = getDataFromConfig(address)) { + return when (val configData = getDataFromConfig(address, proDataContext)) { is RecipientData.Self -> { createLocalRecipient(address, configData) } @@ -359,7 +525,7 @@ class RecipientRepository @Inject constructor( is RecipientData.Contact -> { createContactRecipient( address = address, - basic = configData, + configData = configData, fallbackSettings = settingsFetcher(address) ) } @@ -369,6 +535,7 @@ class RecipientRepository @Inject constructor( // with the settings fetched from the database. createGenericRecipient( address = address, + proDataContext = proDataContext, settings = settingsFetcher(address), ) } @@ -390,7 +557,8 @@ class RecipientRepository @Inject constructor( return fetchRecipient( address = address, settingsFetcher = recipientSettingsDatabase::getSettings, - communityFetcher = communityDatabase::getRoomInfo + communityFetcher = communityDatabase::getRoomInfo, + needFlow = false, ).first } @@ -400,31 +568,60 @@ class RecipientRepository @Inject constructor( * Note that some of the data might not be available in the config system so it's your * responsibility to fill in the gaps if needed. */ - private fun getDataFromConfig(address: Address): RecipientData.ConfigBased? { + private fun getDataFromConfig( + address: Address, + proDataContext: ProDataContext? + ): RecipientData? { return when (address) { is Address.Standard -> { // Is this our own address? - if (address.address.equals(preferences.getLocalNumber(), ignoreCase = true)) { + if (address.address.equals( + loginStateRepository.requireLocalNumber(), + ignoreCase = true + ) + ) { configFactory.withUserConfigs { configs -> + val pro = configs.userProfile.getProConfig() + + if (pro != null) { + proDataContext?.addProData( + RecipientSettings.ProData( + showProBadge = configs.userProfile.getProFeatures().contains( + ProProfileFeature.PRO_BADGE + ), + expiry = Instant.ofEpochMilli(pro.proProof.expiryMs), + genIndexHash = pro.proProof.genIndexHashHex, + ) + ) + } + RecipientData.Self( name = configs.userProfile.getName().orEmpty(), avatar = configs.userProfile.getPic().toRemoteFile(), expiryMode = configs.userProfile.getNtsExpiry(), priority = configs.userProfile.getNtsPriority(), - proStatus = if (preferences.forceCurrentUserAsPro()) { - ProStatus.Pro() - } else { - // TODO: Get pro status from config - ProStatus.None - }, - profileUpdatedAt = null + proData = null, // final ProData will be calculated later + profileUpdatedAt = null, ) } } else { // Is this a contact? + configFactory.withUserConfigs { configs -> - configs.contacts.get(address.accountId.hexString) - }?.let { contact -> + configs.contacts.get(address.accountId.hexString)?.let { + it to configs.convoInfoVolatile.getOneToOne(address.accountId.hexString) + } + }?.let { (contact, convo) -> + if (convo?.proProofInfo != null && proDataContext != null) { + proDataContext.addProData( + RecipientSettings.ProData( + showProBadge = contact.proFeatures.contains(ProProfileFeature.PRO_BADGE), + expiry = convo.proProofInfo!!.expiry, + genIndexHash = convo.proProofInfo!!.genIndexHash.data.toHexString(), + ) + ) + } + RecipientData.Contact( name = contact.name, nickname = contact.nickname.takeIf { it.isNotBlank() }, @@ -434,12 +631,7 @@ class RecipientRepository @Inject constructor( blocked = contact.blocked, expiryMode = contact.expiryMode, priority = contact.priority, - proStatus = if (preferences.forceOtherUsersAsPro()) { - ProStatus.Pro() - } else { - //TODO: Get contact's pro status from config - ProStatus.None - }, + proData = null, // final ProData will be calculated later profileUpdatedAt = contact.profileUpdatedEpochSeconds.secondsToInstant(), ) } @@ -450,25 +642,29 @@ class RecipientRepository @Inject constructor( // Is this a group? is Address.Group -> { val groupInfo = configFactory.getGroup(address.accountId) ?: return null - val groupMemberComparator = GroupMemberComparator(AccountId(preferences.getLocalNumber()!!)) + val groupMemberComparator = + GroupMemberComparator(loginStateRepository.requireLocalAccountId()) + configFactory.withGroupConfigs(address.accountId) { configs -> - RecipientData.PartialGroup( + RecipientData.Group( avatar = configs.groupInfo.getProfilePic().toRemoteFile(), expiryMode = configs.groupInfo.expiryMode, name = configs.groupInfo.getName() ?: groupInfo.name, - proStatus = if (preferences.forceOtherUsersAsPro()) ProStatus.Pro() else { - // TODO: Get group's pro status from config? - ProStatus.None - }, + proData = null, // final ProData will be calculated later description = configs.groupInfo.getDescription(), members = configs.groupMembers.all() .asSequence() .map(RecipientData::GroupMemberInfo) .sortedWith { o1, o2 -> - groupMemberComparator.compare(o1.address.accountId, o2.address.accountId) + groupMemberComparator.compare( + o1.address.accountId, + o2.address.accountId + ) } .toList(), groupInfo = groupInfo, + firstMember = null, + secondMember = null, ) } } @@ -477,18 +673,27 @@ class RecipientRepository @Inject constructor( is Address.Blinded, is Address.CommunityBlindedId -> { val blinded = address.toBlinded() ?: return null - val contact = configFactory.withUserConfigs { it.contacts.getBlinded(blinded.blindedId.hexString) } ?: return null + val (contact, convo) = configFactory.withUserConfigs { configs -> + configs.contacts.getBlinded(blinded.blindedId.hexString)?.let { + it to configs.convoInfoVolatile.getBlindedOneToOne(blinded.blindedId.hexString) + } + } ?: return null + + if (convo?.proProofInfo != null && proDataContext != null) { + proDataContext.addProData( + RecipientSettings.ProData( + showProBadge = contact.proFeatures.contains(ProProfileFeature.PRO_BADGE), + expiry = convo.proProofInfo!!.expiry, + genIndexHash = convo.proProofInfo!!.genIndexHash.data.toHexString(), + ) + ) + } RecipientData.BlindedContact( displayName = contact.name, avatar = contact.profilePic.toRemoteFile(), priority = contact.priority, - proStatus = if (preferences.forceOtherUsersAsPro()) { - ProStatus.Pro() - } else { - //TODO: Get blinded contact's pro status from? - ProStatus.None - }, + proData = null, // final ProData will be calculated later // This information is not available in the config but we infer that // if you already have this person as blinded contact, you would have been @@ -509,6 +714,7 @@ class RecipientRepository @Inject constructor( */ private fun createGenericRecipient( address: Address, + proDataContext: ProDataContext?, settings: RecipientSettings, // Additional data for group members, if available. groupMemberInfo: RecipientData.GroupMemberInfo? = null, @@ -517,16 +723,17 @@ class RecipientRepository @Inject constructor( "Address must match the group member info address if provided." } + if (settings.proData != null && proDataContext != null) { + proDataContext.addProData(settings.proData) + } + return Recipient( address = address, data = RecipientData.Generic( - displayName = settings.name?.takeIf { it.isNotBlank() } ?: groupMemberInfo?.name.orEmpty(), - avatar = settings.profilePic?.toRemoteFile() ?: groupMemberInfo?.profilePic?.toRemoteFile(), - proStatus = if (preferences.forceOtherUsersAsPro()) { - ProStatus.Pro() - } else { - settings.proStatus - }, + displayName = settings.name?.takeIf { it.isNotBlank() } + ?: groupMemberInfo?.name.orEmpty(), + avatar = settings.profilePic?.toRemoteFile() + ?: groupMemberInfo?.profilePic?.toRemoteFile(), acceptsBlindedCommunityMessageRequests = !settings.blocksCommunityMessagesRequests, ), mutedUntil = settings.muteUntil, @@ -537,19 +744,19 @@ class RecipientRepository @Inject constructor( private inline fun createGroupV2Recipient( address: Address, - partial: RecipientData.PartialGroup, + proDataContext: ProDataContext?, + configData: RecipientData.Group, settings: RecipientSettings?, settingsFetcher: (Address) -> RecipientSettings, ): Recipient { return Recipient( address = address, - data = RecipientData.Group( - partial = partial, - firstMember = partial.members.firstOrNull()?.let { member -> - fetchGroupMember(member, settingsFetcher) + data = configData.copy( + firstMember = configData.members.firstOrNull()?.let { member -> + fetchGroupMember(proDataContext?.takeIf { member.isAdmin }, member, settingsFetcher) } ?: getSelf(), // Fallback to have self as first member if no members are present - secondMember = partial.members.getOrNull(1)?.let { member -> - fetchGroupMember(member, settingsFetcher) + secondMember = configData.members.getOrNull(1)?.let { member -> + fetchGroupMember(proDataContext?.takeIf { member.isAdmin }, member, settingsFetcher) }, ), mutedUntil = settings?.muteUntil, @@ -559,6 +766,7 @@ class RecipientRepository @Inject constructor( } private inline fun createLegacyGroupRecipient( + proDataContext: ProDataContext?, address: Address, config: GroupInfo.LegacyGroupInfo?, group: GroupRecord, // Local db data @@ -572,7 +780,7 @@ class RecipientRepository @Inject constructor( .toMutableList() - val myAccountId = AccountId(preferences.getLocalNumber()!!) + val myAccountId = loginStateRepository.requireLocalAccountId() val groupMemberComparator = GroupMemberComparator(myAccountId) memberAddresses.sortedWith { a1, a2 -> @@ -592,9 +800,10 @@ class RecipientRepository @Inject constructor( } }, firstMember = memberAddresses.firstOrNull() - ?.let { fetchLegacyGroupMember(it, settingsFetcher) } + ?.let { fetchLegacyGroupMember(it, proDataContext, settingsFetcher) } ?: getSelf(), // Fallback to have self as first member if no members are present - secondMember = memberAddresses.getOrNull(1)?.let { fetchLegacyGroupMember(it, settingsFetcher) }, + secondMember = memberAddresses.getOrNull(1) + ?.let { fetchLegacyGroupMember(it, proDataContext, settingsFetcher) }, isCurrentUserAdmin = Address.Standard(myAccountId) in group.admins ), mutedUntil = settings?.muteUntil, @@ -604,17 +813,9 @@ class RecipientRepository @Inject constructor( } - companion object { private const val TAG = "RecipientRepository" - private fun createLocalRecipient(address: Address, basic: RecipientData.Self): Recipient { - return Recipient( - address = address, - data = basic, - autoDownloadAttachments = true, - ) - } private val ReadableGroupInfoConfig.expiryMode: ExpiryMode get() { @@ -625,14 +826,22 @@ class RecipientRepository @Inject constructor( } } + private fun createLocalRecipient(address: Address, configData: RecipientData.Self): Recipient { + return Recipient( + address = address, + data = configData, + autoDownloadAttachments = true, + ) + } + private fun createContactRecipient( address: Address, - basic: RecipientData.Contact, + configData: RecipientData.Contact, fallbackSettings: RecipientSettings?, // Local db data ): Recipient { return Recipient( address = address, - data = basic, + data = configData, mutedUntil = fallbackSettings?.muteUntil, autoDownloadAttachments = fallbackSettings?.autoDownloadAttachments, notifyType = fallbackSettings?.notifyType ?: NotifyType.ALL, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientSettingsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientSettingsDatabase.kt index a310fc5837..823d3a5c33 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientSettingsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientSettingsDatabase.kt @@ -5,6 +5,7 @@ import android.content.Context import android.database.Cursor import android.database.sqlite.SQLiteDatabase import androidx.collection.LruCache +import androidx.sqlite.db.SupportSQLiteDatabase import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -12,7 +13,6 @@ import kotlinx.serialization.json.Json import network.loki.messenger.libsession_util.util.Bytes import network.loki.messenger.libsession_util.util.UserPic import org.session.libsession.utilities.Address -import org.session.libsession.utilities.recipients.ProStatus import org.session.libsession.utilities.recipients.RemoteFile import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log @@ -120,14 +120,8 @@ class RecipientSettingsDatabase @Inject constructor( ), blocksCommunityMessagesRequests = getInt(getColumnIndexOrThrow(COL_BLOCKS_COMMUNITY_MESSAGES_REQUESTS)) == 1, name = getString(getColumnIndexOrThrow(COL_NAME)), - proStatus = getString(getColumnIndexOrThrow(COL_PRO_STATUS)) - ?.let { - runCatching { - json.get().decodeFromString(it) - }.getOrNull() - } - ?: ProStatus.None, profileUpdated = getLong(getColumnIndexOrThrow(COL_PROFILE_UPDATE_TIME)).millsToInstant(), + proData = getString(getColumnIndexOrThrow(COL_PRO_DATA))?.let(json.get()::decodeFromString), ) } @@ -140,8 +134,8 @@ class RecipientSettingsDatabase @Inject constructor( put(COL_PROFILE_PIC_KEY, profilePic?.key?.data?.let(Base64::encodeBytes)) put(COL_PROFILE_PIC_URL, profilePic?.url) put(COL_BLOCKS_COMMUNITY_MESSAGES_REQUESTS, blocksCommunityMessagesRequests) - put(COL_PRO_STATUS, json.get().encodeToString(proStatus)) put(COL_PROFILE_UPDATE_TIME, profileUpdated?.toEpochMilli() ?: 0L) + put(COL_PRO_DATA, proData?.let { json.get().encodeToString(it) }) } } @@ -242,8 +236,13 @@ class RecipientSettingsDatabase @Inject constructor( private const val COL_PROFILE_PIC_URL = "profile_pic_url" private const val COL_NAME = "name" private const val COL_BLOCKS_COMMUNITY_MESSAGES_REQUESTS = "blocks_community_messages_requests" + + @Deprecated("Old column kept for migrations") private const val COL_PRO_STATUS = "pro_status" + private const val COL_PRO_DATA = "pro_data" + + // The time when the profile pic/name/is_pro was last updated, in epoch seconds. private const val COL_PROFILE_UPDATE_TIME = "profile_update_time" @@ -296,6 +295,14 @@ class RecipientSettingsDatabase @Inject constructor( DROP TABLE recipient_preferences """ + fun migrateProStatusToProData(db: SupportSQLiteDatabase) { + // By the time we release this code, the pro_status column is actually only filled with nulls, + // so we can simply remove the column + db.execSQL("ALTER TABLE $TABLE_NAME DROP COLUMN $COL_PRO_STATUS") + + db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN $COL_PRO_DATA TEXT DEFAULT NULL") + } + private fun readUserProfile(keyB64: String?, url: String?): UserPic? { return if (keyB64.isNullOrBlank() || url.isNullOrEmpty()) { null diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 38c1d6eb34..7a6204dfe6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -22,48 +22,40 @@ import android.content.ContentValues; import android.content.Context; import android.database.Cursor; -import android.text.TextUtils; +import androidx.collection.ArraySet; +import androidx.sqlite.db.SupportSQLiteDatabase; import com.annimon.stream.Stream; import net.zetetic.database.sqlcipher.SQLiteDatabase; import org.json.JSONArray; -import net.zetetic.database.sqlcipher.SQLiteStatement; - -import org.apache.commons.lang3.StringUtils; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import org.session.libsession.messaging.calls.CallMessageType; -import org.session.libsession.messaging.messages.signal.IncomingGroupMessage; import org.session.libsession.messaging.messages.signal.IncomingTextMessage; import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; import org.session.libsession.snode.SnodeAPI; import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.IdentityKeyMismatch; -import org.session.libsession.utilities.IdentityKeyMismatchList; import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.Util; import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsignal.utilities.JsonUtil; import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.guava.Optional; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.MessageId; -import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.database.model.SmsMessageRecord; +import org.thoughtcrime.securesms.pro.ProFeatureExtKt; import java.io.Closeable; -import java.io.IOException; import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Set; import javax.inject.Inject; import javax.inject.Provider; @@ -71,6 +63,10 @@ import dagger.Lazy; import dagger.hilt.android.qualifiers.ApplicationContext; +import network.loki.messenger.libsession_util.protocol.ProFeature; +import network.loki.messenger.libsession_util.protocol.ProMessageFeature; +import network.loki.messenger.libsession_util.protocol.ProProfileFeature; +import network.loki.messenger.libsession_util.util.BitSet; /** * Database for storage of SMS messages. @@ -86,11 +82,14 @@ public class SmsDatabase extends MessagingDatabase { public static final String PERSON = "person"; static final String DATE_RECEIVED = "date"; static final String DATE_SENT = "date_sent"; + @Deprecated(forRemoval = true) public static final String PROTOCOL = "protocol"; public static final String STATUS = "status"; public static final String TYPE = "type"; + @Deprecated(forRemoval = true) public static final String REPLY_PATH_PRESENT = "reply_path_present"; public static final String SUBJECT = "subject"; + @Deprecated(forRemoval = true) public static final String SERVICE_CENTER = "service_center"; private static final String IS_DELETED_COLUMN_DEF = IS_DELETED + " GENERATED ALWAYS AS ((" + TYPE + @@ -115,36 +114,14 @@ public class SmsDatabase extends MessagingDatabase { "CREATE INDEX IF NOT EXISTS sms_thread_date_index ON " + TABLE_NAME + " (" + THREAD_ID + ", " + DATE_RECEIVED + ");" }; - private static final String[] MESSAGE_PROJECTION = new String[] { - ID, THREAD_ID, ADDRESS, ADDRESS_DEVICE_ID, PERSON, - DATE_RECEIVED + " AS " + NORMALIZED_DATE_RECEIVED, - DATE_SENT + " AS " + NORMALIZED_DATE_SENT, - PROTOCOL, READ, STATUS, TYPE, - REPLY_PATH_PRESENT, SUBJECT, BODY, SERVICE_CENTER, DELIVERY_RECEIPT_COUNT, - MISMATCHED_IDENTITIES, SUBSCRIPTION_ID, EXPIRES_IN, EXPIRE_STARTED, - NOTIFIED, READ_RECEIPT_COUNT, HAS_MENTION, - "json_group_array(json_object(" + - "'" + ReactionDatabase.ROW_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.ROW_ID + ", " + - "'" + ReactionDatabase.MESSAGE_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + ", " + - "'" + ReactionDatabase.IS_MMS + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + ", " + - "'" + ReactionDatabase.AUTHOR_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.AUTHOR_ID + ", " + - "'" + ReactionDatabase.EMOJI + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.EMOJI + ", " + - "'" + ReactionDatabase.SERVER_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.SERVER_ID + ", " + - "'" + ReactionDatabase.COUNT + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.COUNT + ", " + - "'" + ReactionDatabase.SORT_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.SORT_ID + ", " + - "'" + ReactionDatabase.DATE_SENT + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_SENT + ", " + - "'" + ReactionDatabase.DATE_RECEIVED + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_RECEIVED + - ")) AS " + ReactionDatabase.REACTION_JSON_ALIAS - }; - - public static String CREATE_REACTIONS_UNREAD_COMMAND = "ALTER TABLE "+ TABLE_NAME + " " + + public static final String CREATE_REACTIONS_UNREAD_COMMAND = "ALTER TABLE "+ TABLE_NAME + " " + "ADD COLUMN " + REACTIONS_UNREAD + " INTEGER DEFAULT 0;"; - public static String CREATE_HAS_MENTION_COMMAND = "ALTER TABLE "+ TABLE_NAME + " " + + public static final String CREATE_HAS_MENTION_COMMAND = "ALTER TABLE "+ TABLE_NAME + " " + "ADD COLUMN " + HAS_MENTION + " INTEGER DEFAULT 0;"; - private static String COMMA_SEPARATED_COLUMNS = ID + ", " + THREAD_ID + ", " + ADDRESS + ", " + ADDRESS_DEVICE_ID + ", " + PERSON + ", " + DATE_RECEIVED + ", " + DATE_SENT + ", " + PROTOCOL + ", " + READ + ", " + STATUS + ", " + TYPE + ", " + REPLY_PATH_PRESENT + ", " + DELIVERY_RECEIPT_COUNT + ", " + SUBJECT + ", " + BODY + ", " + MISMATCHED_IDENTITIES + ", " + SERVICE_CENTER + ", " + SUBSCRIPTION_ID + ", " + EXPIRES_IN + ", " + EXPIRE_STARTED + ", " + NOTIFIED + ", " + READ_RECEIPT_COUNT + ", " + UNIDENTIFIED + ", " + REACTIONS_UNREAD + ", " + HAS_MENTION; - private static String TEMP_TABLE_NAME = "TEMP_TABLE_NAME"; + private static final String COMMA_SEPARATED_COLUMNS = ID + ", " + THREAD_ID + ", " + ADDRESS + ", " + ADDRESS_DEVICE_ID + ", " + PERSON + ", " + DATE_RECEIVED + ", " + DATE_SENT + ", " + PROTOCOL + ", " + READ + ", " + STATUS + ", " + TYPE + ", " + REPLY_PATH_PRESENT + ", " + DELIVERY_RECEIPT_COUNT + ", " + SUBJECT + ", " + BODY + ", " + MISMATCHED_IDENTITIES + ", " + SERVICE_CENTER + ", " + SUBSCRIPTION_ID + ", " + EXPIRES_IN + ", " + EXPIRE_STARTED + ", " + NOTIFIED + ", " + READ_RECEIPT_COUNT + ", " + UNIDENTIFIED + ", " + REACTIONS_UNREAD + ", " + HAS_MENTION; + private static final String TEMP_TABLE_NAME = "TEMP_TABLE_NAME"; public static final String[] ADD_AUTOINCREMENT = new String[]{ "ALTER TABLE " + TABLE_NAME + " RENAME TO " + TEMP_TABLE_NAME, @@ -160,6 +137,11 @@ public class SmsDatabase extends MessagingDatabase { public static final String ADD_IS_DELETED_COLUMN = "ALTER TABLE " + TABLE_NAME + " ADD COLUMN " + IS_DELETED_COLUMN_DEF; public static final String ADD_IS_GROUP_UPDATE_COLUMN = "ALTER TABLE " + TABLE_NAME +" ADD COLUMN " + IS_GROUP_UPDATE +" BOOL GENERATED ALWAYS AS (" + TYPE +" & " + GROUP_UPDATE_MESSAGE_BIT +" != 0) VIRTUAL"; + public static void addProFeatureColumns(SupportSQLiteDatabase db) { + db.execSQL("ALTER TABLE " + TABLE_NAME + " ADD COLUMN " + PRO_MESSAGE_FEATURES + " INTEGER NOT NULL DEFAULT 0"); + db.execSQL("ALTER TABLE " + TABLE_NAME + " ADD COLUMN " + PRO_PROFILE_FEATURES + " INTEGER NOT NULL DEFAULT 0"); + } + private static final EarlyReceiptCache earlyDeliveryReceiptCache = new EarlyReceiptCache(); private static final EarlyReceiptCache earlyReadReceiptCache = new EarlyReceiptCache(); @@ -201,42 +183,27 @@ public long getThreadIdForMessage(long id) { String[] sqlArgs = new String[] {id+""}; SQLiteDatabase db = getReadableDatabase(); - Cursor cursor = null; - - try { - cursor = db.rawQuery(sql, sqlArgs); - if (cursor != null && cursor.moveToFirst()) - return cursor.getLong(0); - else - return -1; - } finally { - if (cursor != null) - cursor.close(); + try (Cursor cursor = db.rawQuery(sql, sqlArgs)) { + if (cursor != null && cursor.moveToFirst()) + return cursor.getLong(0); + else + return -1; } } public int getMessageCountForThread(long threadId) { SQLiteDatabase db = getReadableDatabase(); - Cursor cursor = null; - try { - cursor = db.query(TABLE_NAME, new String[] {"COUNT(*)"}, THREAD_ID + " = ?", - new String[] {threadId+""}, null, null, null); + try (Cursor cursor = db.query(TABLE_NAME, new String[]{"COUNT(*)"}, THREAD_ID + " = ?", + new String[]{threadId + ""}, null, null, null)) { - if (cursor != null && cursor.moveToFirst()) - return cursor.getInt(0); - } finally { - if (cursor != null) - cursor.close(); + if (cursor != null && cursor.moveToFirst()) + return cursor.getInt(0); } return 0; } - public void markAsDecryptFailed(long id) { - updateTypeBitmask(id, Types.ENCRYPTION_MASK, Types.ENCRYPTION_REMOTE_FAILED_BIT); - } - @Override public void markAsSent(long id, boolean isSent) { updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SENT_TYPE | (isSent ? Types.PUSH_MESSAGE_BIT | Types.SECURE_MESSAGE_BIT : 0)); @@ -327,6 +294,39 @@ public boolean isOutgoingMessage(long id) { return isOutgoing; } + public int getOutgoingMessageProFeatureCount(long featureMask) { + return getOutgoingProFeatureCountInternal(PRO_MESSAGE_FEATURES, featureMask); + } + + public int getOutgoingProfileProFeatureCount(long featureMask) { + return getOutgoingProFeatureCountInternal(PRO_PROFILE_FEATURES, featureMask); + } + + private int getOutgoingProFeatureCountInternal(@NonNull String columnName, long featureMask) { + SQLiteDatabase db = getReadableDatabase(); + + // outgoing clause + StringBuilder outgoingTypes = new StringBuilder(); + long[] types = MmsSmsColumns.Types.OUTGOING_MESSAGE_TYPES; + for (int i = 0; i < types.length; i++) { + if (i > 0) outgoingTypes.append(","); + outgoingTypes.append(types[i]); + } + + String outgoingSelection = + "(" + TYPE + " & " + MmsSmsColumns.Types.BASE_TYPE_MASK + ") IN (" + outgoingTypes + ")"; + + String where = "(" + columnName + " & " + featureMask + ") != 0 AND " + outgoingSelection; + + try (Cursor cursor = db.query(TABLE_NAME, new String[]{"COUNT(*)"}, where, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } + } + + return 0; + } + public boolean isDeletedMessage(long id) { SQLiteDatabase database = getWritableDatabase(); Cursor cursor = null; @@ -402,19 +402,11 @@ public List setMessagesRead(long threadId) { return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0)", new String[] {String.valueOf(threadId)}); } - public List setAllMessagesRead() { - return setMessagesRead(READ + " = 0", null); - } - private List setMessagesRead(String where, String[] arguments) { SQLiteDatabase database = getWritableDatabase(); List results = new LinkedList<>(); - Cursor cursor = null; - database.beginTransaction(); - try { - cursor = database.query(TABLE_NAME, new String[] {ID, ADDRESS, DATE_SENT, TYPE, EXPIRES_IN, EXPIRE_STARTED}, where, arguments, null, null, null); - + try (final Cursor cursor = database.query(TABLE_NAME, new String[] {ID, ADDRESS, DATE_SENT, TYPE, EXPIRES_IN, EXPIRE_STARTED}, where, arguments, null, null, null)) { while (cursor != null && cursor.moveToNext()) { long timestamp = cursor.getLong(2); SyncMessageId syncMessageId = new SyncMessageId(Address.fromSerialized(cursor.getString(1)), timestamp); @@ -430,7 +422,6 @@ private List setMessagesRead(String where, String[] arguments database.update(TABLE_NAME, contentValues, where, arguments); database.setTransactionSuccessful(); } finally { - if (cursor != null) cursor.close(); database.endTransaction(); } @@ -445,16 +436,9 @@ public void updateSentTimestamp(long messageId, long newTimestamp) { protected Optional insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runThreadUpdate) { Address recipient = message.getSender(); + Address groupRecipient = message.getGroup(); - Address groupRecipient; - - if (message.getGroupId() == null) { - groupRecipient = null; - } else { - groupRecipient = message.getGroupId(); - } - - boolean unread = (message.isSecureMessage() || message.isGroup() || message.isUnreadCallMessage()); + boolean unread = (message.isSecureMessage() || message.isGroupMessage() || message.isUnreadCallMessage()); long threadId; @@ -463,46 +447,38 @@ protected Optional insertMessageInbox(IncomingTextMessage message, if (message.isSecureMessage()) { type |= Types.SECURE_MESSAGE_BIT; - } else if (message.isGroup()) { + } else if (message.isGroupMessage()) { type |= Types.SECURE_MESSAGE_BIT; - if (((IncomingGroupMessage)message).isUpdateMessage()) type |= GROUP_UPDATE_MESSAGE_BIT; + if (message.isGroupUpdateMessage()) type |= GROUP_UPDATE_MESSAGE_BIT; } - if (message.isPush()) type |= Types.PUSH_MESSAGE_BIT; + if (message.getPush()) type |= Types.PUSH_MESSAGE_BIT; if (message.isOpenGroupInvitation()) type |= Types.OPEN_GROUP_INVITATION_BIT; - CallMessageType callMessageType = message.getCallType(); + CallMessageType callMessageType = message.getCallMessageType(); if (callMessageType != null) { type |= getCallMessageTypeMask(callMessageType); } ContentValues values = new ContentValues(6); values.put(ADDRESS, message.getSender().toString()); - values.put(ADDRESS_DEVICE_ID, message.getSenderDeviceId()); // In open groups messages should be sorted by their server timestamp long receivedTimestamp = serverTimestamp; if (serverTimestamp == 0) { receivedTimestamp = message.getSentTimestampMillis(); } values.put(DATE_RECEIVED, receivedTimestamp); // Loki - This is important due to how we handle GIFs values.put(DATE_SENT, message.getSentTimestampMillis()); - values.put(PROTOCOL, message.getProtocol()); values.put(READ, unread ? 0 : 1); - values.put(SUBSCRIPTION_ID, message.getSubscriptionId()); - values.put(EXPIRES_IN, message.getExpiresIn()); + values.put(EXPIRES_IN, message.getExpiresInMillis()); values.put(EXPIRE_STARTED, message.getExpireStartedAt()); - values.put(UNIDENTIFIED, message.isUnidentified()); - values.put(HAS_MENTION, message.hasMention()); - - if (!TextUtils.isEmpty(message.getPseudoSubject())) - values.put(SUBJECT, message.getPseudoSubject()); - - values.put(REPLY_PATH_PRESENT, message.isReplyPathPresent()); - values.put(SERVICE_CENTER, message.getServiceCenterAddress()); - values.put(BODY, message.getMessageBody()); + values.put(HAS_MENTION, message.getHasMention()); + values.put(BODY, message.getMessage()); values.put(TYPE, type); values.put(THREAD_ID, threadId); + values.put(PRO_MESSAGE_FEATURES, ProFeatureExtKt.toProMessageBitSetValue(message.getProFeatures())); + values.put(PRO_PROFILE_FEATURES, ProFeatureExtKt.toProProfileBitSetValue(message.getProFeatures())); - if (message.isPush() && isDuplicate(message, threadId)) { + if (message.getPush() && isDuplicate(message, threadId)) { Log.w(TAG, "Duplicate message (" + message.getSentTimestampMillis() + "), ignoring..."); return Optional.absent(); } else { @@ -560,9 +536,8 @@ public long insertMessageOutbox(long threadId, OutgoingTextMessage message, boolean forceSms, long date, boolean runThreadUpdate) { - long type = Types.BASE_SENDING_TYPE; + long type = Types.BASE_SENDING_TYPE | Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT; - if (message.isSecureMessage()) type |= (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT); if (forceSms) type |= Types.MESSAGE_FORCE_SMS_BIT; if (message.isOpenGroupInvitation()) type |= Types.OPEN_GROUP_INVITATION_BIT; @@ -573,16 +548,17 @@ public long insertMessageOutbox(long threadId, OutgoingTextMessage message, ContentValues contentValues = new ContentValues(); contentValues.put(ADDRESS, address.toString()); contentValues.put(THREAD_ID, threadId); - contentValues.put(BODY, message.getMessageBody()); + contentValues.put(BODY, message.getMessage()); contentValues.put(DATE_RECEIVED, SnodeAPI.getNowWithOffset()); contentValues.put(DATE_SENT, message.getSentTimestampMillis()); contentValues.put(READ, 1); contentValues.put(TYPE, type); - contentValues.put(SUBSCRIPTION_ID, message.getSubscriptionId()); - contentValues.put(EXPIRES_IN, message.getExpiresIn()); - contentValues.put(EXPIRE_STARTED, message.getExpireStartedAt()); + contentValues.put(EXPIRES_IN, message.getExpiresInMillis()); + contentValues.put(EXPIRE_STARTED, message.getExpireStartedAtMillis()); contentValues.put(DELIVERY_RECEIPT_COUNT, Stream.of(earlyDeliveryReceipts.values()).mapToLong(Long::longValue).sum()); contentValues.put(READ_RECEIPT_COUNT, Stream.of(earlyReadReceipts.values()).mapToLong(Long::longValue).sum()); + contentValues.put(PRO_MESSAGE_FEATURES, ProFeatureExtKt.toProMessageBitSetValue(message.getProFeatures())); + contentValues.put(PRO_PROFILE_FEATURES, ProFeatureExtKt.toProProfileBitSetValue(message.getProFeatures())); if (isDuplicate(message, threadId)) { Log.w(TAG, "Duplicate message (" + message.getSentTimestampMillis() + "), ignoring..."); @@ -604,15 +580,6 @@ public long insertMessageOutbox(long threadId, OutgoingTextMessage message, return messageId; } - - private Cursor rawQuery(@NonNull String where, @Nullable String[] arguments) { - SQLiteDatabase database = getReadableDatabase(); - return database.rawQuery("SELECT " + Util.join(MESSAGE_PROJECTION, ",") + - " FROM " + SmsDatabase.TABLE_NAME + " LEFT OUTER JOIN " + ReactionDatabase.TABLE_NAME + - " ON (" + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " = " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 0)" + - " WHERE " + where + " GROUP BY " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID, arguments); - } - @Override public List getExpiredMessageIDs(long nowMills) { String query = "SELECT " + ID + " FROM " + TABLE_NAME + @@ -645,22 +612,7 @@ public long getNextExpiringTimestamp() { } } - @NonNull - public SmsMessageRecord getMessage(long messageId) throws NoSuchMessageException { - final SmsMessageRecord record = getMessageOrNull(messageId); - - if (record == null) throw new NoSuchMessageException("No message for ID: " + messageId); - else return record; - } - - @Nullable - public SmsMessageRecord getMessageOrNull(long messageId) { - try (final Cursor cursor = rawQuery(ID_WHERE, new String[]{String.valueOf(messageId)})) { - return new Reader(cursor).getNext(); - } - } - - @Override + @Override public void deleteMessage(long messageId) { doDeleteMessages(true, ID + " = ?", messageId); } @@ -681,12 +633,7 @@ public void updateThreadId(long fromId, long toId) { db.update(TABLE_NAME, contentValues, THREAD_ID + " = ?", new String[] {fromId + ""}); } - @Override - public MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException { - return getMessage(messageId); - } - - private boolean isDuplicate(IncomingTextMessage message, long threadId) { + private boolean isDuplicate(IncomingTextMessage message, long threadId) { SQLiteDatabase database = getReadableDatabase(); Cursor cursor = database.query(TABLE_NAME, null, DATE_SENT + " = ? AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?", new String[]{String.valueOf(message.getSentTimestampMillis()), message.getSender().toString(), String.valueOf(threadId)}, @@ -788,7 +735,6 @@ public int getCount() { public SmsMessageRecord getCurrent() { long messageId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.ID)); Address address = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.ADDRESS))); - int addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.ADDRESS_DEVICE_ID)); long type = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.TYPE)); long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.NORMALIZED_DATE_RECEIVED)); long dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.NORMALIZED_DATE_SENT)); @@ -796,39 +742,41 @@ public SmsMessageRecord getCurrent() { int status = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.STATUS)); int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.DELIVERY_RECEIPT_COUNT)); int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.READ_RECEIPT_COUNT)); - String mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.MISMATCHED_IDENTITIES)); - int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.SUBSCRIPTION_ID)); long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.EXPIRES_IN)); long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.EXPIRE_STARTED)); String body = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.BODY)); boolean hasMention = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.HAS_MENTION)) == 1; + final ArraySet proFeatures = new ArraySet<>(); + ProFeatureExtKt.toProMessageFeatures(cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.PRO_MESSAGE_FEATURES)), proFeatures); + ProFeatureExtKt.toProProfileFeatures(cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.PRO_PROFILE_FEATURES)), proFeatures); + if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { readReceiptCount = 0; } - List mismatches = getMismatches(mismatchDocument); Recipient recipient = recipientRepository.getRecipientSync(address); List reactions = reactionDatabase.get().getReactions(cursor); - return new SmsMessageRecord(messageId, body, recipient, - recipient, - dateSent, dateReceived, deliveryReceiptCount, type, - threadId, status, mismatches, - expiresIn, expireStarted, readReceiptCount, reactions, hasMention); + return new SmsMessageRecord( + messageId, + body, + recipient, + recipient, + dateSent, + dateReceived, + deliveryReceiptCount, + type, + threadId, + status, + expiresIn, + expireStarted, + readReceiptCount, + reactions, + hasMention, + proFeatures); } - private List getMismatches(String document) { - try { - if (!TextUtils.isEmpty(document)) { - return JsonUtil.fromJson(document, IdentityKeyMismatchList.class).getList(); - } - } catch (IOException e) { - Log.w(TAG, e); - } - - return new LinkedList<>(); - } @Override public void close() { 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 564a2a8234..c0c8576408 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -4,10 +4,15 @@ import android.content.Context import android.net.Uri import dagger.Lazy import dagger.hilt.android.qualifiers.ApplicationContext -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import network.loki.messenger.libsession_util.MutableConversationVolatileConfig +import network.loki.messenger.libsession_util.PRIORITY_PINNED +import network.loki.messenger.libsession_util.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.ReadableUserGroupsConfig +import network.loki.messenger.libsession_util.protocol.ProFeature +import network.loki.messenger.libsession_util.protocol.ProMessageFeature +import network.loki.messenger.libsession_util.protocol.ProProfileFeature import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.Bytes import network.loki.messenger.libsession_util.util.Conversation @@ -23,19 +28,16 @@ import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.MessageSendJob import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.GroupUpdated -import org.session.libsession.messaging.messages.signal.IncomingEncryptedMessage -import org.session.libsession.messaging.messages.signal.IncomingGroupMessage import org.session.libsession.messaging.messages.signal.IncomingMediaMessage import org.session.libsession.messaging.messages.signal.IncomingTextMessage -import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.visible.Attachment -import org.session.libsession.messaging.messages.visible.Profile import org.session.libsession.messaging.messages.visible.Reaction import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment +import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier @@ -49,7 +51,6 @@ import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.GroupDisplayInfo import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupUtil -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.isCommunity import org.session.libsession.utilities.isCommunityInbox @@ -59,12 +60,10 @@ import org.session.libsession.utilities.upsertContact import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceAttachmentPointer -import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional -import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord @@ -72,7 +71,6 @@ import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.mms.PartAuthority -import org.thoughtcrime.securesms.util.DateUtils.Companion.secondsToInstant import org.thoughtcrime.securesms.util.FilenameUtils import org.thoughtcrime.securesms.util.SessionMetaProtocol import java.time.Instant @@ -80,7 +78,6 @@ import java.time.ZoneId import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton -import kotlin.math.max import network.loki.messenger.libsession_util.util.GroupMember as LibSessionGroupMember private const val TAG = "Storage" @@ -94,7 +91,6 @@ open class Storage @Inject constructor( private val threadDatabase: ThreadDatabase, private val recipientDatabase: RecipientSettingsDatabase, private val attachmentDatabase: AttachmentDatabase, - private val draftDatabase: DraftDatabase, private val lokiAPIDatabase: LokiAPIDatabase, private val groupDatabase: GroupDatabase, private val lokiMessageDatabase: LokiMessageDatabase, @@ -105,43 +101,26 @@ open class Storage @Inject constructor( private val notificationManager: MessageNotifier, private val messageDataProvider: MessageDataProvider, private val clock: SnodeClock, - private val preferences: TextSecurePreferences, private val openGroupManager: Lazy, private val recipientRepository: RecipientRepository, + private val loginStateRepository: LoginStateRepository, ) : Database(context, helper), StorageProtocol { - override fun getUserPublicKey(): String? { return preferences.getLocalNumber() } + override fun getUserPublicKey(): String? { return loginStateRepository.peekLoginState()?.accountId?.hexString } - override fun getUserX25519KeyPair(): ECKeyPair { return lokiAPIDatabase.getUserX25519KeyPair() } + override fun getUserX25519KeyPair(): KeyPair = requireNotNull(loginStateRepository.peekLoginState()) { + "No logged in state available" + }.accountX25519KeyPair - override fun getUserED25519KeyPair(): KeyPair? { return KeyPairUtilities.getUserED25519KeyPair(context) } + override fun getUserED25519KeyPair(): KeyPair? { + return loginStateRepository.peekLoginState()?.accountEd25519KeyPair + } override fun getUserBlindedAccountId(serverPublicKey: String): AccountId? { val myId = getUserPublicKey() ?: return null return AccountId(BlindKeyAPI.blind15Ids(myId, serverPublicKey).first()) } - override fun getUserProfile(): Profile { - return configFactory.withUserConfigs { configs -> - val pic = configs.userProfile.getPic() - Profile( - displayName = configs.userProfile.getName(), - profilePictureURL = pic.url.takeIf { it.isNotBlank() }, - profileKey = pic.key.data.takeIf { pic.url.isNotBlank() }, - profileUpdated = configs.userProfile.getProfileUpdatedSeconds().secondsToInstant(), - ) - } - } - - override fun getOrGenerateRegistrationID(): Int { - var registrationID = TextSecurePreferences.getLocalRegistrationId(context) - if (registrationID == 0) { - registrationID = KeyHelper.generateRegistrationId(false) - TextSecurePreferences.setLocalRegistrationId(context, registrationID) - } - return registrationID - } - override fun getAttachmentsForMessage(mmsMessageId: Long): List { return attachmentDatabase.getAttachmentsForMessage(mmsMessageId) } @@ -345,20 +324,20 @@ open class Storage @Inject constructor( } val quote: Optional = if (quotes != null) Optional.of(quotes) else Optional.absent() - val linkPreviews: Optional> = if (linkPreview.isEmpty()) Optional.absent() else Optional.of(linkPreview.mapNotNull { it!! }) + val linkPreviews = linkPreview.mapNotNull { it } val insertResult = if (isUserSender || isUserBlindedSender) { val pointers = attachments.mapNotNull { it.toSignalAttachment() } - val mediaMessage = OutgoingMediaMessage.from( - message, - targetAddress, - pointers, - quote.orNull(), - linkPreviews.orNull()?.firstOrNull(), - expiresInMillis, - expireStartedAt + val mediaMessage = OutgoingMediaMessage( + message = message, + recipient = targetAddress, + attachments = pointers, + outgoingQuote = quote.orNull(), + linkPreview = linkPreviews.firstOrNull(), + expiresInMillis = expiresInMillis, + expireStartedAt = expireStartedAt ) mmsDatabase.insertSecureDecryptedMessageOutbox(mediaMessage, message.threadID ?: -1, message.sentTimestamp!!, runThreadUpdate) } else { @@ -366,7 +345,16 @@ open class Storage @Inject constructor( val signalServiceAttachments = attachments.mapNotNull { it.toSignalPointer() } - val mediaMessage = IncomingMediaMessage.from(message, senderAddress, expiresInMillis, expireStartedAt, Optional.fromNullable(threadRecipient.address as? Address.GroupLike), signalServiceAttachments, quote, linkPreviews) + val mediaMessage = IncomingMediaMessage( + message = message, + from = senderAddress, + expiresIn = expiresInMillis, + expireStartedAt = expireStartedAt, + group = threadRecipient.address as? Address.GroupLike, + attachments = PointerAttachment.forPointers(Optional.of(signalServiceAttachments)), + quote = quotes, + linkPreviews = linkPreviews + ) mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, message.threadID!!, message.receivedTimestamp ?: 0, runThreadUpdate) } @@ -376,14 +364,37 @@ open class Storage @Inject constructor( val isOpenGroupInvitation = (message.openGroupInvitation != null) val insertResult = if (isUserSender || isUserBlindedSender) { - val textMessage = if (isOpenGroupInvitation) OutgoingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, targetAddress, message.sentTimestamp, expiresInMillis, expireStartedAt) - else OutgoingTextMessage.from(message, targetAddress, expiresInMillis, expireStartedAt) + val textMessage = if (isOpenGroupInvitation) OutgoingTextMessage.fromOpenGroupInvitation( + invitation = message.openGroupInvitation!!, + recipient = targetAddress, + sentTimestampMillis = message.sentTimestamp!!, + expiresInMillis = expiresInMillis, + expireStartedAtMillis = expireStartedAt + )!! + else OutgoingTextMessage( + message = message, + recipient = targetAddress, + expiresInMillis = expiresInMillis, + expireStartedAtMillis = expireStartedAt + ) + smsDatabase.insertMessageOutbox(message.threadID ?: -1, textMessage, message.sentTimestamp!!, runThreadUpdate) } else { - val textMessage = if (isOpenGroupInvitation) IncomingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, senderAddress, message.sentTimestamp, expiresInMillis, expireStartedAt) - else IncomingTextMessage.from(message, senderAddress, Optional.fromNullable(threadRecipient.address as? Address.GroupLike), expiresInMillis, expireStartedAt) - val encrypted = IncomingEncryptedMessage(textMessage, textMessage.messageBody) - smsDatabase.insertMessageInbox(encrypted, message.receivedTimestamp ?: 0, runThreadUpdate) + val textMessage = if (isOpenGroupInvitation) IncomingTextMessage.fromOpenGroupInvitation( + invitation = message.openGroupInvitation!!, + sender = senderAddress, + sentTimestampMillis = message.sentTimestamp!!, + expiresInMillis = expiresInMillis, + expireStartedAt = expireStartedAt + )!! + else IncomingTextMessage( + message = message, + sender = senderAddress, + group = threadRecipient.address as? Address.GroupLike, + expiresInMillis = expiresInMillis, + expireStartedAt = expireStartedAt + ) + smsDatabase.insertMessageInbox(textMessage.copy(isSecureMessage = true), message.receivedTimestamp ?: 0, runThreadUpdate) } messageID = insertResult.orNull()?.messageId?.let { MessageId(it, mms = false) } } @@ -659,40 +670,6 @@ open class Storage @Inject constructor( groupDatabase.updateMembers(groupID, members) } - override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, threadID: Long, sentTimestamp: Long): Long? { - val userPublicKey = getUserPublicKey()!! - val recipient = fromSerialized(groupID) - val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() ?: "" - val infoMessage = OutgoingGroupMediaMessage( - recipient, - updateData, - groupID, - null, - sentTimestamp, - 0, - 0, - true, - null, - listOf(), - listOf(), - null - ) - val mmsDB = mmsDatabase - val mmsSmsDB = mmsSmsDatabase - if (mmsSmsDB.getMessageFor(threadID, sentTimestamp, userPublicKey) != null) { - Log.w(TAG, "Bailing from insertOutgoingInfoMessage because we believe the message has already been sent!") - return null - } - val infoMessageID = mmsDB.insertMessageOutbox( - infoMessage, - threadID, - false, - runThreadUpdate = true - ) - mmsDB.markAsSent(infoMessageID, true) - return infoMessageID - } - override fun isLegacyClosedGroup(publicKey: String): Boolean { return lokiAPIDatabase.isClosedGroup(publicKey) } @@ -812,30 +789,29 @@ open class Storage @Inject constructor( private fun insertUpdateControlMessage(updateData: UpdateMessageData, sentTimestamp: Long, senderPublicKey: String?, closedGroup: AccountId): MessageId? { val userPublicKey = getUserPublicKey()!! - val address = fromSerialized(closedGroup.hexString) + val address = Address.Group(closedGroup) val recipient = recipientRepository.getRecipientSync(address) val threadDb = threadDatabase val threadID = threadDb.getThreadIdIfExistsFor(address) val expiryMode = recipient.expiryMode - val expiresInMillis = expiryMode?.expiryMillis ?: 0 + val expiresInMillis = expiryMode.expiryMillis val expireStartedAt = if (expiryMode is ExpiryMode.AfterSend) sentTimestamp else 0 val inviteJson = updateData.toJSON() if (senderPublicKey == null || senderPublicKey == userPublicKey) { - val infoMessage = OutgoingGroupMediaMessage( - address, - inviteJson, - closedGroup.hexString, - null, - sentTimestamp, - expiresInMillis, - expireStartedAt, - true, - null, - listOf(), - listOf(), - null + val infoMessage = OutgoingMediaMessage( + recipient = address, + body = inviteJson, + group = address, + avatar = null, + sentTimeMillis = sentTimestamp, + expiresInMillis = expiresInMillis, + expireStartedAtMillis = expireStartedAt, + isGroupUpdateMessage = true, + quote = null, + previews = listOf(), + messageContent = null ) val mmsDB = mmsDatabase val mmsSmsDB = mmsSmsDatabase @@ -850,10 +826,27 @@ open class Storage @Inject constructor( mmsDB.markAsSent(infoMessageID, true) return MessageId(infoMessageID, mms = true) } else { - val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(Address.Group(closedGroup)), expiresInMillis, expireStartedAt, true, false) - val infoMessage = IncomingGroupMessage(m, inviteJson, true) + val m = IncomingTextMessage( + message = inviteJson, + sender = fromSerialized(senderPublicKey), + sentTimestampMillis = sentTimestamp, + group = Address.Group(closedGroup), + push = true, + expiresInMillis = expiresInMillis, + expireStartedAt = expireStartedAt, + callType = -1, + hasMention = false, + isOpenGroupInvitation = false, + isSecureMessage = false, + proFeatures = emptySet(), + isGroupMessage = true, + isGroupUpdateMessage = true, + ) val smsDB = smsDatabase - val insertResult = smsDB.insertMessageInbox(infoMessage, true) + val insertResult = smsDB.insertMessageInbox(m.copy( + isGroupUpdateMessage = true, + message = inviteJson + ), true) return insertResult.orNull()?.messageId?.let { MessageId(it, mms = false) } } } @@ -884,12 +877,7 @@ open class Storage @Inject constructor( } override fun getThreadIdForMms(mmsId: Long): Long { - val mmsDb = mmsDatabase - val cursor = mmsDb.getMessage(mmsId) - val reader = mmsDb.readerFor(cursor) - val threadId = reader.next?.threadId - cursor.close() - return threadId ?: -1 + return mmsDatabase.getThreadIdForMessage(mmsId) } override fun getRecipientForThread(threadId: Long): Recipient? { @@ -962,6 +950,24 @@ open class Storage @Inject constructor( } } + override suspend fun getTotalSentProBadges(): Int = + getTotalSentForFeature(ProProfileFeature.PRO_BADGE) + + override suspend fun getTotalSentLongMessages(): Int = + getTotalSentForFeature(ProMessageFeature.HIGHER_CHARACTER_LIMIT) + + suspend fun getTotalSentForFeature(feature: ProFeature): Int = withContext(Dispatchers.IO) { + val mask = 1L shl feature.bitIndex + + when (feature) { + is ProMessageFeature -> + mmsSmsDatabase.getOutgoingMessageProFeatureCount(mask) + + is ProProfileFeature -> + mmsSmsDatabase.getOutgoingProfileProFeatureCount(mask) + } + } + override fun setPinned(address: Address, isPinned: Boolean) { val isLocalNumber = address.address == getUserPublicKey() configFactory.withMutableUserConfigs { configs -> @@ -1060,19 +1066,18 @@ open class Storage @Inject constructor( val mediaMessage = IncomingMediaMessage( address, sentTimestamp, - -1, expiresInMillis, expireStartedAt, false, false, - Optional.absent(), - Optional.absent(), - Optional.absent(), null, - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.of(message) + null, + emptyList(), + emptySet(), + null, + null, + emptyList(), + message ) mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true) @@ -1085,21 +1090,20 @@ open class Storage @Inject constructor( val userPublicKey = getUserPublicKey() ?: return val message = IncomingMediaMessage( - fromSerialized(userPublicKey), - clock.currentTimeMills(), - -1, - 0, - 0, - true, - false, - Optional.absent(), - Optional.absent(), - Optional.absent(), - null, - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent() + from = fromSerialized(userPublicKey), + sentTimeMillis = clock.currentTimeMills(), + expiresIn = 0, + expireStartedAt = 0, + isMessageRequestResponse = true, + hasMention = false, + body = null, + group = null, + attachments = emptyList(), + proFeatures = emptySet(), + messageContent = null, + quote = null, + linkPreviews = emptyList(), + dataExtractionNotification = null ) mmsDatabase.insertSecureDecryptedMessageInbox(message, threadId, runThreadUpdate = true) } @@ -1110,7 +1114,14 @@ open class Storage @Inject constructor( val expiryMode = recipient.expiryMode.coerceSendToRead() val expiresInMillis = expiryMode.expiryMillis val expireStartedAt = if (expiryMode != ExpiryMode.NONE) clock.currentTimeMills() else 0 - val callMessage = IncomingTextMessage.fromCallInfo(callMessageType, address, Optional.absent(), sentTimestamp, expiresInMillis, expireStartedAt) + val callMessage = IncomingTextMessage( + callMessageType = callMessageType, + sender = address, + group = null, + sentTimestampMillis = sentTimestamp, + expiresInMillis = expiresInMillis, + expireStartedAt = expireStartedAt + ) smsDatabase.insertCallMessage(callMessage) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index ec0cdbf912..068ab51fbc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -38,7 +38,6 @@ import org.session.libsession.utilities.AddressKt; import org.session.libsession.utilities.ConfigFactoryProtocol; import org.session.libsession.utilities.ConfigFactoryProtocolKt; -import org.session.libsession.utilities.DistributionTypes; import org.session.libsession.utilities.GroupUtil; import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.Util; @@ -56,7 +55,7 @@ import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; -import org.thoughtcrime.securesms.notifications.MarkReadReceiver; +import org.thoughtcrime.securesms.notifications.MarkReadProcessor; import org.thoughtcrime.securesms.util.SharedConfigUtilsKt; import java.io.Closeable; @@ -239,6 +238,7 @@ public static void migrateLegacyCommunityAddresses(final SQLiteDatabase db) { private final Lazy<@NonNull MessageNotifier> messageNotifier; private final Lazy<@NonNull MmsDatabase> mmsDatabase; private final Lazy<@NonNull SmsDatabase> smsDatabase; + private final Lazy<@NonNull MarkReadProcessor> markReadProcessor; @Inject public ThreadDatabase(@dagger.hilt.android.qualifiers.ApplicationContext Context context, @@ -249,6 +249,7 @@ public ThreadDatabase(@dagger.hilt.android.qualifiers.ApplicationContext Context Lazy<@NonNull MessageNotifier> messageNotifier, Lazy<@NonNull MmsDatabase> mmsDatabase, Lazy<@NonNull SmsDatabase> smsDatabase, + Lazy<@NonNull MarkReadProcessor> markReadProcessor, TextSecurePreferences prefs, Json json) { super(context, databaseHelper); @@ -258,6 +259,7 @@ public ThreadDatabase(@dagger.hilt.android.qualifiers.ApplicationContext Context this.messageNotifier = messageNotifier; this.mmsDatabase = mmsDatabase; this.smsDatabase = smsDatabase; + this.markReadProcessor = markReadProcessor; this.json = json; this.prefs = prefs; @@ -489,22 +491,6 @@ public void setCreationDates(@NonNull final Map dates) { } } - public int getDistributionType(long threadId) { - SQLiteDatabase db = getReadableDatabase(); - Cursor cursor = db.query(TABLE_NAME, new String[]{DISTRIBUTION_TYPE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null); - - try { - if (cursor != null && cursor.moveToNext()) { - return cursor.getInt(cursor.getColumnIndexOrThrow(DISTRIBUTION_TYPE)); - } - - return DistributionTypes.DEFAULT; - } finally { - if (cursor != null) cursor.close(); - } - - } - @NonNull public List getThreads(@Nullable Collection addresses) { if (addresses == null || addresses.isEmpty()) @@ -768,7 +754,7 @@ public boolean isRead(long threadId) { public boolean markAllAsRead(long threadId, long lastSeenTime, boolean force, boolean updateNotifications) { if (mmsSmsDatabase.get().getConversationCount(threadId) <= 0 && !force) return false; List messages = setRead(threadId, lastSeenTime); - MarkReadReceiver.process(context, messages); + markReadProcessor.get().process(messages); if(updateNotifications) messageNotifier.get().updateNotification(context, threadId); return setLastSeen(threadId, lastSeenTime); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index f7b51cef35..d82ba6b813 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.database.PushDatabase; import org.thoughtcrime.securesms.database.PushRegistrationDatabase; import org.thoughtcrime.securesms.database.ReactionDatabase; +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientSettingsDatabase; import org.thoughtcrime.securesms.database.SearchDatabase; @@ -40,6 +41,7 @@ import org.thoughtcrime.securesms.database.SessionJobDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.pro.db.ProDatabase; import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities; import javax.inject.Provider; @@ -102,9 +104,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV53 = 74; private static final int lokiV54 = 75; private static final int lokiV55 = 76; + private static final int lokiV56 = 77; + private static final int lokiV57 = 78; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV55; + private static final int DATABASE_VERSION = lokiV57; private static final int MIN_DATABASE_VERSION = lokiV7; public static final String DATABASE_NAME = "session.db"; @@ -264,6 +268,13 @@ public void onCreate(SQLiteDatabase db) { db.execSQL(MmsDatabase.ADD_LAST_MESSAGE_INDEX); executeStatements(db, PushRegistrationDatabase.Companion.createTableStatements()); + + ReceivedMessageHashDatabase.Companion.createAndMigrateTable(db); + + ProDatabase.Companion.createTable(db); + MmsDatabase.Companion.addProFeatureColumns(db); + SmsDatabase.addProFeatureColumns(db); + RecipientSettingsDatabase.Companion.migrateProStatusToProData(db); } @Override @@ -602,6 +613,17 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { executeStatements(db, PushRegistrationDatabase.Companion.createTableStatements()); } + if (oldVersion < lokiV56) { + ReceivedMessageHashDatabase.Companion.createAndMigrateTable(db); + } + + if (oldVersion < lokiV57) { + ProDatabase.Companion.createTable(db); + MmsDatabase.Companion.addProFeatureColumns(db); + SmsDatabase.addProFeatureColumns(db); + RecipientSettingsDatabase.Companion.migrateProStatusToProData(db); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java index 7fbcf9669e..239de984d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java @@ -16,21 +16,19 @@ */ package org.thoughtcrime.securesms.database.model; -import android.content.Context; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview; -import org.session.libsession.utilities.Contact; -import org.session.libsession.utilities.IdentityKeyMismatch; -import org.session.libsession.utilities.NetworkFailure; import org.session.libsession.utilities.recipients.Recipient; import org.thoughtcrime.securesms.database.SmsDatabase.Status; import org.thoughtcrime.securesms.database.model.content.MessageContent; import org.thoughtcrime.securesms.mms.SlideDeck; import java.util.List; +import java.util.Set; + +import network.loki.messenger.libsession_util.protocol.ProFeature; /** * Represents the message record model for MMS messages that contain @@ -41,40 +39,29 @@ */ public class MediaMmsMessageRecord extends MmsMessageRecord { - private final int partCount; public MediaMmsMessageRecord(long id, Recipient conversationRecipient, - Recipient individualRecipient, int recipientDeviceId, + Recipient individualRecipient, long dateSent, long dateReceived, int deliveryReceiptCount, long threadId, String body, @NonNull SlideDeck slideDeck, - int partCount, long mailbox, - List mismatches, - List failures, int subscriptionId, + long mailbox, long expiresIn, long expireStarted, int readReceiptCount, - @Nullable Quote quote, @NonNull List contacts, + @Nullable Quote quote, @NonNull List linkPreviews, @NonNull List reactions, boolean hasMention, - @Nullable MessageContent messageContent) + @Nullable MessageContent messageContent, + Set proFeatures) { super(id, body, conversationRecipient, individualRecipient, dateSent, - dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures, - expiresIn, expireStarted, slideDeck, readReceiptCount, quote, contacts, - linkPreviews, reactions, hasMention, messageContent); - this.partCount = partCount; + dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, + expiresIn, expireStarted, slideDeck, readReceiptCount, quote, + linkPreviews, reactions, hasMention, messageContent, proFeatures); } - public int getPartCount() { - return partCount; - } - - @Override + @Override public boolean isMmsNotification() { return false; } - @Override - public CharSequence getDisplayBody(@NonNull Context context) { - return super.getDisplayBody(context); - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index 65afa14e86..ce04203b84 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -20,8 +20,6 @@ import android.text.Spannable; import android.text.SpannableString; import android.text.style.ForegroundColorSpan; -import android.text.style.RelativeSizeSpan; -import android.text.style.StyleSpan; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -33,8 +31,6 @@ import org.session.libsession.messaging.utilities.UpdateMessageData; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.AddressKt; -import org.session.libsession.utilities.IdentityKeyMismatch; -import org.session.libsession.utilities.NetworkFailure; import org.session.libsession.utilities.ThemeUtil; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsignal.utilities.AccountId; @@ -44,8 +40,10 @@ import java.util.List; import java.util.Objects; +import java.util.Set; import network.loki.messenger.R; +import network.loki.messenger.libsession_util.protocol.ProFeature; /** * The base class for message record models that are displayed in @@ -57,8 +55,6 @@ */ public abstract class MessageRecord extends DisplayRecord { private final Recipient individualRecipient; - private final List mismatches; - private final List networkFailures; private final long expiresIn; private final long expireStarted; public final long id; @@ -67,6 +63,7 @@ public abstract class MessageRecord extends DisplayRecord { @Nullable private UpdateMessageData groupUpdateMessage; + public final Set proFeatures; public abstract boolean isMms(); public abstract boolean isMmsNotification(); @@ -79,22 +76,20 @@ public final MessageId getMessageId() { Recipient individualRecipient, long dateSent, long dateReceived, long threadId, int deliveryStatus, int deliveryReceiptCount, long type, - List mismatches, - List networkFailures, long expiresIn, long expireStarted, int readReceiptCount, List reactions, boolean hasMention, - @Nullable MessageContent messageContent) + @Nullable MessageContent messageContent, + Set proFeatures) { super(body, conversationRecipient, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, readReceiptCount, messageContent); this.id = id; this.individualRecipient = individualRecipient; - this.mismatches = mismatches; - this.networkFailures = networkFailures; this.expiresIn = expiresIn; this.expireStarted = expireStarted; this.reactions = reactions; this.hasMention = hasMention; + this.proFeatures = proFeatures; } public long getId() { @@ -109,9 +104,7 @@ public Recipient getIndividualRecipient() { public long getType() { return type; } - public List getNetworkFailures() { - return networkFailures; - } + public long getExpiresIn() { return expiresIn; } @@ -197,15 +190,7 @@ public boolean isGroupExpirationTimerUpdate() { return updateMessageData != null && updateMessageData.getKind() instanceof UpdateMessageData.Kind.GroupExpirationUpdated; } - protected SpannableString emphasisAdded(String sequence) { - SpannableString spannable = new SpannableString(sequence); - spannable.setSpan(new RelativeSizeSpan(0.9f), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - - return spannable; - } - - @Override + @Override public boolean equals(Object other) { return other instanceof MessageRecord && ((MessageRecord) other).getId() == getId() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java index d0cc28a597..30e75f7475 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java @@ -4,84 +4,86 @@ import androidx.annotation.Nullable; import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview; -import org.session.libsession.utilities.Contact; -import org.session.libsession.utilities.IdentityKeyMismatch; -import org.session.libsession.utilities.NetworkFailure; import org.session.libsession.utilities.recipients.Recipient; import org.thoughtcrime.securesms.database.model.content.MessageContent; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; -import java.util.LinkedList; +import java.util.ArrayList; import java.util.List; +import java.util.Set; + +import network.loki.messenger.libsession_util.protocol.ProFeature; public abstract class MmsMessageRecord extends MessageRecord { - private final @NonNull SlideDeck slideDeck; - private final @Nullable Quote quote; - private final @NonNull List contacts = new LinkedList<>(); - private final @NonNull List linkPreviews = new LinkedList<>(); - - MmsMessageRecord(long id, String body, Recipient conversationRecipient, - Recipient individualRecipient, long dateSent, - long dateReceived, long threadId, int deliveryStatus, int deliveryReceiptCount, - long type, List mismatches, - List networkFailures, long expiresIn, - long expireStarted, @NonNull SlideDeck slideDeck, int readReceiptCount, - @Nullable Quote quote, @NonNull List contacts, - @NonNull List linkPreviews, List reactions, boolean hasMention, - @Nullable MessageContent messageContent) - { - super(id, body, conversationRecipient, individualRecipient, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, expiresIn, expireStarted, readReceiptCount, reactions, hasMention, messageContent); - this.slideDeck = slideDeck; - this.quote = quote; - this.contacts.addAll(contacts); - this.linkPreviews.addAll(linkPreviews); - } - - @Override - public boolean isMms() { - return true; - } - - @NonNull - public SlideDeck getSlideDeck() { - return slideDeck; - } - - @Override - public boolean isMediaPending() { - for (Slide slide : getSlideDeck().getSlides()) { - if (slide.isInProgress() || slide.isPendingDownload()) { + private final @NonNull SlideDeck slideDeck; + private final @Nullable Quote quote; + private final @NonNull List linkPreviews = new ArrayList<>(); + + MmsMessageRecord(long id, String body, Recipient conversationRecipient, + Recipient individualRecipient, long dateSent, + long dateReceived, long threadId, int deliveryStatus, int deliveryReceiptCount, + long type, + long expiresIn, + long expireStarted, @NonNull SlideDeck slideDeck, int readReceiptCount, + @Nullable Quote quote, + @NonNull List linkPreviews, List reactions, boolean hasMention, + @Nullable MessageContent messageContent, + Set proFeatures) { + super(id, body, conversationRecipient, individualRecipient, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, expiresIn, expireStarted, readReceiptCount, reactions, hasMention, messageContent, proFeatures); + this.slideDeck = slideDeck; + this.quote = quote; + this.linkPreviews.addAll(linkPreviews); + } + + @Override + public boolean isMms() { return true; - } } - return false; - } - - public boolean containsMediaSlide() { - return slideDeck.containsMediaSlide(); - } - public @Nullable Quote getQuote() { - return quote; - } - public @NonNull List getSharedContacts() { - return contacts; - } - public @NonNull List getLinkPreviews() { - return linkPreviews; - } - - public boolean hasAttachmentUri() { - boolean hasData = false; - - for (Slide slide : slideDeck.getSlides()) { - if (slide.getUri() != null || slide.getThumbnailUri() != null) { - hasData = true; - break; - } + @Override + public boolean isMmsNotification() { + return false; + } + + @NonNull + public SlideDeck getSlideDeck() { + return slideDeck; + } + + @Override + public boolean isMediaPending() { + for (Slide slide : getSlideDeck().getSlides()) { + if (slide.isInProgress() || slide.isPendingDownload()) { + return true; + } + } + + return false; + } + + public boolean containsMediaSlide() { + return slideDeck.containsMediaSlide(); } - return hasData; - } + public @Nullable Quote getQuote() { + return quote; + } + + public @NonNull List getLinkPreviews() { + return linkPreviews; + } + + public boolean hasAttachmentUri() { + boolean hasData = false; + + for (Slide slide : slideDeck.getSlides()) { + if (slide.getUri() != null || slide.getThumbnailUri() != null) { + hasData = true; + break; + } + } + + return hasData; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ReactionRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/ReactionRecord.kt index bbe7d854b6..13fd9ce9d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/ReactionRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ReactionRecord.kt @@ -6,6 +6,13 @@ data class ReactionRecord( val author: String, val emoji: String, val serverId: String = "", + /** + * The meaning of count depends on the context: + * - When the message is from community, this count represents the total number of reactions of this type and + * it is the same across all ReactionRecords for the same emoji/messageId. + * - When the message is from a private chat, this count should be added up across all ReactionRecords for the + * same emoji/messageId to get the total number of reactions of this type. + */ val count: Long = 0, val sortId: Long = 0, val dateSent: Long = 0, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientSettings.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientSettings.kt index 011e51b182..a0780bd64f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientSettings.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientSettings.kt @@ -1,7 +1,12 @@ package org.thoughtcrime.securesms.database.model +import kotlinx.serialization.Serializable +import network.loki.messenger.libsession_util.protocol.ProFeature +import network.loki.messenger.libsession_util.protocol.ProProfileFeature +import network.loki.messenger.libsession_util.util.BitSet +import network.loki.messenger.libsession_util.util.Conversation import network.loki.messenger.libsession_util.util.UserPic -import org.session.libsession.utilities.recipients.ProStatus +import org.session.libsession.utilities.serializable.InstantAsMillisSerializer import java.time.Instant /** @@ -14,6 +19,27 @@ data class RecipientSettings( val autoDownloadAttachments: Boolean = false, val profilePic: UserPic? = null, val blocksCommunityMessagesRequests: Boolean = true, - val proStatus: ProStatus = ProStatus.None, val profileUpdated: Instant? = null, -) + val proData: ProData? = null, +) { + @Serializable + data class ProData( + @Serializable(with = InstantAsMillisSerializer::class) + val expiry: Instant, + val genIndexHash: String, + val showProBadge: Boolean, + ) { + + constructor( + info: Conversation.ProProofInfo, + features: BitSet, + ): this( + expiry = info.expiry, + genIndexHash = info.genIndexHash.data.toHexString(), + showProBadge = features.contains(ProProfileFeature.PRO_BADGE), + ) + fun isExpired(now: Instant): Boolean { + return expiry <= now + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java index 46aab667fe..f9aa2e2846 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java @@ -21,11 +21,12 @@ import androidx.annotation.NonNull; -import org.session.libsession.utilities.IdentityKeyMismatch; import org.session.libsession.utilities.recipients.Recipient; -import java.util.LinkedList; import java.util.List; +import java.util.Set; + +import network.loki.messenger.libsession_util.protocol.ProFeature; /** * The message record model which represents standard SMS messages. @@ -35,38 +36,38 @@ */ public class SmsMessageRecord extends MessageRecord { - public SmsMessageRecord(long id, - String body, Recipient recipient, - Recipient individualRecipient, - long dateSent, long dateReceived, - int deliveryReceiptCount, - long type, long threadId, - int status, List mismatches, - long expiresIn, long expireStarted, - int readReceiptCount, List reactions, boolean hasMention) - { - super(id, body, recipient, individualRecipient, - dateSent, dateReceived, threadId, status, deliveryReceiptCount, type, - mismatches, new LinkedList<>(), - expiresIn, expireStarted, readReceiptCount, reactions, hasMention, null); - } + public SmsMessageRecord(long id, + String body, Recipient recipient, + Recipient individualRecipient, + long dateSent, long dateReceived, + int deliveryReceiptCount, + long type, long threadId, + int status, + long expiresIn, long expireStarted, + int readReceiptCount, List reactions, boolean hasMention, + Set proFeatures) { + super(id, body, recipient, individualRecipient, + dateSent, dateReceived, threadId, status, deliveryReceiptCount, type, + expiresIn, expireStarted, readReceiptCount, reactions, hasMention, null, + proFeatures); + } - public long getType() { - return type; - } + public long getType() { + return type; + } - @Override - public CharSequence getDisplayBody(@NonNull Context context) { - return super.getDisplayBody(context); - } + @Override + public CharSequence getDisplayBody(@NonNull Context context) { + return super.getDisplayBody(context); + } - @Override - public boolean isMms() { - return false; - } + @Override + public boolean isMms() { + return false; + } - @Override - public boolean isMmsNotification() { - return false; - } + @Override + public boolean isMmsNotification() { + return false; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java index 2023848d3a..5c7f2f073e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java @@ -37,6 +37,7 @@ import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.recipients.RecipientNamesKt; +import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate; @@ -159,7 +160,8 @@ else if (MmsSmsColumns.Types.isMediaSavedExtraction(type)) { } else if (MmsSmsColumns.Types.isMessageRequestResponse(type)) { try { if (lastMessage.getRecipient().getAddress().toString().equals( - TextSecurePreferences.getLocalNumber(context))) { + ((ApplicationContext) context.getApplicationContext()).getLoginStateRepository() + .get().getLocalNumber())) { return UtilKt.getSubbedCharSequence( context, R.string.messageRequestYouHaveAccepted, diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt index d89c3b78c0..458968bb4b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt @@ -9,8 +9,8 @@ class DebugActivity : FullComposeActivity() { @Composable override fun ComposeContent() { - DebugMenuScreen( - onClose = { finish() } + DebugMenuNavHost( + onBack = { finish() } ) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt new file mode 100644 index 0000000000..bc197eb605 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt @@ -0,0 +1,209 @@ +package org.thoughtcrime.securesms.debugmenu + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.DropDown +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.bold +import org.thoughtcrime.securesms.ui.theme.monospace +import org.thoughtcrime.securesms.util.DateUtils +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + + +@Composable +fun DebugLogScreen( + viewModel: DebugMenuViewModel, + onBack: () -> Unit, +){ + val flowLogs = remember { viewModel.debugLogs } + val logs by flowLogs.collectAsStateWithLifecycle(initialValue = emptyList()) + + DebugLogs( + logs = logs, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DebugLogs( + logs: List, + sendCommand: (DebugMenuViewModel.Commands) -> Unit, + onBack: () -> Unit, +){ + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + // App bar + BackAppBar(title = "Debug Logs", onBack = onBack) + }, + ) { contentPadding -> + val scrollState = rememberLazyListState() + + Column( + modifier = Modifier.fillMaxSize() + .padding(contentPadding) + .padding(LocalDimensions.current.smallSpacing) + ) { + var filter: DebugLogGroup? by remember { mutableStateOf(null) } + + DropDown( + selected = filter, + values = DebugLogGroup.entries, + onValueSelected = { filter = it }, + labeler = { it?.label ?: "Show All" }, + allowSelectingNullValue = true, + ) + + Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + + Cell( + modifier = Modifier.weight(1f), + ) { + val haptics = LocalHapticFeedback.current + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(LocalDimensions.current.smallSpacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing), + state = scrollState + ) { + items(items = logs.filter { filter == null || it.group == filter }) { log -> + Column( + modifier = Modifier.fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures( + onLongPress = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + sendCommand(DebugMenuViewModel.Commands.CopyLog(log)) + } + ) + } + ) { + Row { + val locale = remember(Unit) { Locale.getDefault() } + val formatter = remember(Unit){ DateTimeFormatter.ofPattern("HH:mm", locale)} + + Text( + text = Instant.ofEpochMilli(log.date.toEpochMilli()) + .atZone(ZoneId.systemDefault()) + .format(formatter), + style = LocalType.current.small.bold() + ) + + Spacer(Modifier.width(LocalDimensions.current.xxsSpacing)) + + Text( + text = "[${log.group.label}]", + style = LocalType.current.small.bold().copy( + color = log.group.color + ) + ) + } + + Spacer(Modifier.height(2.dp)) + + Text( + text = log.message, + style = LocalType.current.large.monospace().bold() + ) + } + } + } + } + + Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing) + ) { + AccentFillButtonRect( + modifier = Modifier.weight(1f), + text = "Copy all logs", + onClick = { + sendCommand(DebugMenuViewModel.Commands.CopyAllLogs) + } + ) + AccentFillButtonRect( + modifier = Modifier.weight(1f), + text = "Clear logs", + onClick = { + sendCommand(DebugMenuViewModel.Commands.ClearAllDebugLogs) + } + ) + } + } + } +} + +@Preview +@Composable +fun PrewviewDebugLogs( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + DebugLogs( + logs = listOf( + DebugLogData( + message = "This is a log", + group = DebugLogGroup.PRO_SUBSCRIPTION, + date = Instant.now(), + ), + DebugLogData( + message = "This is another log", + group = DebugLogGroup.PRO_SUBSCRIPTION, + date = Instant.now() - Duration.ofMinutes(4), + ), + DebugLogData( + message = "This is also a log", + group = DebugLogGroup.AVATAR, + date = Instant.now() - Duration.ofMinutes(7), + ), + ), + sendCommand = {}, + onBack = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt new file mode 100644 index 0000000000..5b4d3554ba --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt @@ -0,0 +1,134 @@ +package org.thoughtcrime.securesms.debugmenu + +import android.app.Application +import android.widget.Toast +import androidx.compose.ui.graphics.Color +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.ui.theme.primaryBlue +import org.thoughtcrime.securesms.ui.theme.primaryGreen +import org.thoughtcrime.securesms.ui.theme.primaryOrange +import org.thoughtcrime.securesms.util.DateUtils +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +private const val MAX_LOG_ENTRIES = 200 + +/** + * A class that keeps track of certain logs and allows certain logs to pop as toasts + * To use: Set the tag as one of the known [DebugLogGroup] + */ +@Singleton +class DebugLogger @Inject constructor( + private val app: Application, + private val prefs: TextSecurePreferences, + private val dateUtils: DateUtils, + @ManagerScope private val scope: CoroutineScope +) : Log.Logger() { + private val prefPrefix: String = "debug_logger_" + + private val buffer = ArrayDeque(MAX_LOG_ENTRIES) + + private val logChanges = MutableSharedFlow( + replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + private val allowedTags: Set = + DebugLogGroup.entries.map { it.label.lowercase() }.toSet() + + private fun groupForTag(tag: String): DebugLogGroup? = + DebugLogGroup.entries.firstOrNull { it.label.equals(tag, ignoreCase = true) } + + val logSnapshots: Flow> + get() = logChanges.onStart { emit(Unit) }.map { currentSnapshot() } + + // In-memory cache for toast prefs + private val toastEnabled = java.util.EnumMap(DebugLogGroup::class.java).apply { + DebugLogGroup.entries.forEach { group -> + this[group] = prefs.getBooleanPreference(prefPrefix + group.label, false) + } + } + + fun currentSnapshot(): List = + synchronized(buffer) { buffer.toList().asReversed() } + + fun clearAll() { + synchronized(buffer) { buffer.clear() } + logChanges.tryEmit(Unit) + } + + fun showGroupToast(group: DebugLogGroup, showToast: Boolean) { + toastEnabled[group] = showToast + prefs.setBooleanPreference(prefPrefix + group.label, showToast) + } + + fun getGroupToastPreference(group: DebugLogGroup): Boolean = + toastEnabled[group] == true + + // ---- Log.Logger overrides (no “level” logic) ---- + override fun v(tag: String, message: String?, t: Throwable?) = add(tag, message, t) + override fun d(tag: String, message: String?, t: Throwable?) = add(tag, message, t) + override fun i(tag: String, message: String?, t: Throwable?) = add(tag, message, t) + override fun w(tag: String, message: String?, t: Throwable?) = add(tag, message, t) + override fun e(tag: String, message: String?, t: Throwable?) = add(tag, message, t) + override fun wtf(tag: String, message: String?, t: Throwable?) = add(tag, message, t) + override fun blockUntilAllWritesFinished() { /* no-op */ } + + private fun add(tag: String, message: String?, t: Throwable?) { + // Capture ONLY if tag is in our allow-list + if (!allowedTags.contains(tag.lowercase())) return + + val group = groupForTag(tag) ?: return + + val now = Instant.now() + val text = when { + !message.isNullOrBlank() -> message + t != null -> t.localizedMessage ?: t::class.java.simpleName + else -> "" // nothing meaningful + } + + val entry = DebugLogData( + message = text, + group = group, + date = now, + ) + + synchronized(buffer) { + if (buffer.size == MAX_LOG_ENTRIES) buffer.removeFirst() + buffer.addLast(entry) + } + logChanges.tryEmit(Unit) + + // Toast decision is independent from capture. + if (toastEnabled[group] == true) { + scope.launch(Dispatchers.Main) { + Toast.makeText(app, text, Toast.LENGTH_SHORT).show() + } + } + } +} + +data class DebugLogData( + val message: String, + val group: DebugLogGroup, + val date: Instant, +) + +enum class DebugLogGroup(val label: String, val color: Color){ + AVATAR("Avatar", primaryOrange), + PRO_SUBSCRIPTION("ProSubscription", primaryGreen), + PRO_DATA("ProData", primaryBlue) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt index 2cf5359d18..a23216759c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -53,31 +54,28 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import network.loki.messenger.BuildConfig import network.loki.messenger.R +import network.loki.messenger.libsession_util.protocol.ProMessageFeature +import network.loki.messenger.libsession_util.protocol.ProProfileFeature import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager -import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.ChangeEnvironment -import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.ClearTrustedDownloads -import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.Copy07PrefixedBlindedPublicKey -import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.CopyAccountId -import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.HideDeprecationChangeDialog -import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.HideEnvironmentWarningDialog -import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.OverrideDeprecationState -import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.ScheduleTokenNotification -import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.ShowDeprecationChangeDialog -import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.ShowEnvironmentWarningDialog -import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.GenerateContacts -import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.* +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Companion.FALSE +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Companion.NOT_SET +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Companion.SEEN_1 +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Companion.SEEN_2 +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Companion.SEEN_3 +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Companion.SEEN_4 +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Companion.TRUE import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.DialogButtonData +import org.thoughtcrime.securesms.ui.Divider import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.LoadingDialog +import org.thoughtcrime.securesms.ui.components.SlimFillButtonRect import org.thoughtcrime.securesms.ui.components.BackAppBar -import org.thoughtcrime.securesms.ui.components.Button -import org.thoughtcrime.securesms.ui.components.ButtonType import org.thoughtcrime.securesms.ui.components.DropDown import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField import org.thoughtcrime.securesms.ui.components.SessionSwitch -import org.thoughtcrime.securesms.ui.components.SlimOutlineButton import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -216,24 +214,44 @@ fun DebugMenu( ) } - if (uiState.dbInspectorState != DebugMenuViewModel.DatabaseInspectorState.NOT_AVAILABLE) { - DebugCell("Database inspector") { - Button( - onClick = { - sendCommand(DebugMenuViewModel.Commands.ToggleDatabaseInspector) - }, - text = if (uiState.dbInspectorState == DebugMenuViewModel.DatabaseInspectorState.STOPPED) - "Start" - else "Stop", - type = ButtonType.AccentFill, - ) + // Debug Logger + DebugCell( + "Debug Logger", + verticalArrangement = Arrangement.spacedBy(0.dp) + ) + { + Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing)) + + SlimFillButtonRect( + modifier = Modifier.fillMaxWidth(), + text = "Show Debug Logs", + ) { + sendCommand(DebugMenuViewModel.Commands.NavigateTo(DebugMenuDestination.DebugMenuLogs)) + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + Column { + DebugLogGroup.entries.forEach { logGroup -> + DebugSwitchRow( + text = "Show toasts for ${logGroup.label}", + checked = uiState.showToastForGroups[logGroup.label] == true, + onCheckedChange = { + sendCommand(DebugMenuViewModel.Commands.ToggleDebugLogGroup( + group = logGroup, + showToast = it) + ) + } + ) + } } } // Session Pro DebugCell( "Session Pro", - verticalArrangement = Arrangement.spacedBy(0.dp)) { + verticalArrangement = Arrangement.spacedBy(0.dp)) + { Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) Text(text = "Purchase a plan") @@ -278,9 +296,47 @@ fun DebugMenu( ) } ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + DebugSwitchRow( + text = "Is Within Quick Refund Window", + checked = uiState.withinQuickRefund, + onCheckedChange = { + sendCommand(DebugMenuViewModel.Commands.WithinQuickRefund(it)) + } + ) } } + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + Text( + modifier = Modifier.padding(top = LocalDimensions.current.xxsSpacing), + text = "Pro Data Status", + style = LocalType.current.base + ) + DropDown( + modifier = Modifier.fillMaxWidth() + .padding(top = LocalDimensions.current.xxsSpacing), + selectedText = uiState.selectedDebugProPlanStatus.label, + values = uiState.debugProPlanStatus.map { it.label }, + onValueSelected = { selection -> + sendCommand( + DebugMenuViewModel.Commands.SetDebugProPlanStatus( + uiState.debugProPlanStatus.first { it.label == selection } + ) + ) + } + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + DebugSwitchRow( + text = "Force \"No Billing\" APIs", + checked = uiState.forceNoBilling, + onCheckedChange = { + sendCommand(DebugMenuViewModel.Commands.ForceNoBilling(it)) + } + ) + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) DebugSwitchRow( text = "Set all incoming messages as Pro", @@ -291,45 +347,21 @@ fun DebugMenu( ) AnimatedVisibility(uiState.forceIncomingMessagesAsPro) { - Column{ - DebugCheckboxRow( - text = "Message Feature: Pro Badge", - minHeight = 30.dp, - checked = uiState.messageProFeature.contains(ProStatusManager.MessageProFeature.ProBadge), - onCheckedChange = { - sendCommand( - DebugMenuViewModel.Commands.SetMessageProFeature( - ProStatusManager.MessageProFeature.ProBadge, it - ) - ) - } - ) - - DebugCheckboxRow( - text = "Message Feature: Long Message", - minHeight = 30.dp, - checked = uiState.messageProFeature.contains(ProStatusManager.MessageProFeature.LongMessage), - onCheckedChange = { - sendCommand( - DebugMenuViewModel.Commands.SetMessageProFeature( - ProStatusManager.MessageProFeature.LongMessage, it - ) - ) - } - ) - - DebugCheckboxRow( - text = "Message Feature: Animated Avatar", - minHeight = 30.dp, - checked = uiState.messageProFeature.contains(ProStatusManager.MessageProFeature.AnimatedAvatar), - onCheckedChange = { - sendCommand( - DebugMenuViewModel.Commands.SetMessageProFeature( - ProStatusManager.MessageProFeature.AnimatedAvatar, it + Column { + for (feature in (ProMessageFeature.entries + ProProfileFeature.entries)) { + DebugCheckboxRow( + text = "Message Feature: ${feature.name}", + minHeight = 30.dp, + checked = uiState.messageProFeature.contains(feature), + onCheckedChange = { + sendCommand( + DebugMenuViewModel.Commands.SetMessageProFeature( + feature, it + ) ) - ) - } - ) + } + ) + } } } @@ -371,6 +403,85 @@ fun DebugMenu( } } + if (uiState.dbInspectorState != DebugMenuViewModel.DatabaseInspectorState.NOT_AVAILABLE) { + DebugCell("Database inspector") { + SlimFillButtonRect( + modifier = Modifier.fillMaxWidth(), + onClick = { + sendCommand(DebugMenuViewModel.Commands.ToggleDatabaseInspector) + }, + text = if (uiState.dbInspectorState == DebugMenuViewModel.DatabaseInspectorState.STOPPED) + "Start" + else "Stop", + ) + } + } + + // Donations + DebugCell("Donations") { + Text( + text = "First app install: ${uiState.firstInstall}", + style = LocalType.current.base + ) + Text( + text = "Has donated: ${uiState.hasDonated}", + style = LocalType.current.base + ) + Text( + text = "Has copied donate URL: ${uiState.hasCopiedDonationURL}", + style = LocalType.current.base + ) + Text( + text = "Seen donation CTA amount: ${uiState.seenDonateCTAAmount} times", + style = LocalType.current.base + ) + Text( + text = "Last seen donation CTA: ${uiState.lastSeenDonateCTA}", + style = LocalType.current.base + ) + Text( + text = "Show CTA from positive review: ${uiState.showDonateCTAFromPositiveReview}", + style = LocalType.current.base + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) + Divider() + Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) + + DebugDropDownRow( + text = "Debug 'Has donated': ", + selectedText = uiState.hasDonatedDebug, + values = listOf(NOT_SET, TRUE, FALSE), + onValueSelected = { + sendCommand(SetDebugHasDonated(it)) + } + ) + DebugDropDownRow( + text = "Debug 'Has copied link': ", + selectedText = uiState.hasCopiedDonationURLDebug, + values = listOf(NOT_SET, TRUE, FALSE), + onValueSelected = { + sendCommand(SetDebugHasCopiedDonation(it)) + } + ) + DebugDropDownRow( + text = "Debug 'CTA seen amount': ", + selectedText = uiState.seenDonateCTAAmountDebug, + values = listOf(NOT_SET, SEEN_1, SEEN_2, SEEN_3, SEEN_4), + onValueSelected = { + sendCommand(SetDebugDonationCTAViews(it)) + } + ) + DebugDropDownRow( + text = "Debug 'Show donation from app review': ", + selectedText = uiState.showDonateCTAFromPositiveReviewDebug, + values = listOf(NOT_SET, TRUE, FALSE), + onValueSelected = { + sendCommand(SetDebugShowDonationFromReview(it)) + } + ) + } + // Fake contacts DebugCell("Generate fake contacts") { var prefix by remember { mutableStateOf("User-") } @@ -395,7 +506,7 @@ fun DebugMenu( ) } - SlimOutlineButton(modifier = Modifier.fillMaxWidth(), text = "Generate") { + SlimFillButtonRect(modifier = Modifier.fillMaxWidth(), text = "Generate") { sendCommand( GenerateContacts( prefix = prefix, @@ -408,7 +519,7 @@ fun DebugMenu( // Session Token DebugCell("Session Token") { // Schedule a test token-drop notification for 10 seconds from now - SlimOutlineButton( + SlimFillButtonRect( modifier = Modifier.fillMaxWidth(), text = "Schedule Token Page Notification (10s)", onClick = { sendCommand(ScheduleTokenNotification) } @@ -418,7 +529,7 @@ fun DebugMenu( // Keys DebugCell("User Details") { - SlimOutlineButton ( + SlimFillButtonRect ( text = "Copy Account ID", modifier = Modifier.fillMaxWidth(), onClick = { @@ -426,13 +537,21 @@ fun DebugMenu( } ) - SlimOutlineButton( + SlimFillButtonRect( text = "Copy 07-prefixed Version Blinded Public Key", modifier = Modifier.fillMaxWidth(), onClick = { sendCommand(Copy07PrefixedBlindedPublicKey) } ) + + SlimFillButtonRect ( + text = "Copy Pro Master Key", + modifier = Modifier.fillMaxWidth(), + onClick = { + sendCommand(DebugMenuViewModel.Commands.CopyProMasterKey) + } + ) } Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) @@ -455,7 +574,7 @@ fun DebugMenu( } ) - SlimOutlineButton( + SlimFillButtonRect( modifier = Modifier.fillMaxWidth(), text = "Clear All Trusted Downloads", ) { @@ -504,14 +623,14 @@ fun DebugMenu( } ) - SlimOutlineButton( + SlimFillButtonRect( modifier = Modifier.fillMaxWidth(), text = "Reset Push Token", ) { sendCommand(DebugMenuViewModel.Commands.ResetPushToken) } - SlimOutlineButton( + SlimFillButtonRect( modifier = Modifier.fillMaxWidth(), text = "Clear All Trusted Downloads", ) { @@ -684,7 +803,6 @@ private fun DebugRow( Row( modifier = modifier.heightIn(min = minHeight), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xsSpacing) ) { Text( text = title, @@ -692,6 +810,8 @@ private fun DebugRow( modifier = Modifier.weight(1f) ) + Spacer(modifier = Modifier.width(LocalDimensions.current.xsSpacing)) + content() } } @@ -742,7 +862,30 @@ fun DebugCheckboxRow( ) ) } +} +@Composable +fun DebugDropDownRow( + text: String, + selectedText: String, + values: List, + onValueSelected: (String) -> Unit, + modifier: Modifier = Modifier, + minHeight: Dp = LocalDimensions.current.itemButtonIconSpacing, +) { + DebugRow( + title = text, + minHeight = minHeight, + modifier = modifier + .fillMaxWidth(), + ) { + DropDown( + modifier = Modifier.weight(1f, fill = false), + selectedText = selectedText, + values = values, + onValueSelected = onValueSelected + ) + } } @Composable @@ -794,13 +937,27 @@ fun PreviewDebugMenu() { forceOtherUsersAsPro = false, forcePostPro = false, forceShortTTl = false, - messageProFeature = setOf(ProStatusManager.MessageProFeature.AnimatedAvatar), + messageProFeature = setOf(ProMessageFeature.HIGHER_CHARACTER_LIMIT), dbInspectorState = DebugMenuViewModel.DatabaseInspectorState.STARTED, debugSubscriptionStatuses = setOf(DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE), selectedDebugSubscriptionStatus = DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE, + debugProPlanStatus = setOf(DebugMenuViewModel.DebugProPlanStatus.NORMAL), + selectedDebugProPlanStatus = DebugMenuViewModel.DebugProPlanStatus.NORMAL, debugProPlans = emptyList(), + forceNoBilling = false, + withinQuickRefund = true, forceDeterministicEncryption = false, debugAvatarReupload = true, + hasDonated = false, + hasCopiedDonationURL = false, + seenDonateCTAAmount = 0, + lastSeenDonateCTA = "-", + showDonateCTAFromPositiveReview = false, + hasDonatedDebug = "", + hasCopiedDonationURLDebug = "", + seenDonateCTAAmountDebug = "", + showDonateCTAFromPositiveReviewDebug = "", + firstInstall = "" ), sendCommand = {}, onClose = {} diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt new file mode 100644 index 0000000000..491418be66 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt @@ -0,0 +1,111 @@ +package org.thoughtcrime.securesms.debugmenu + +import android.annotation.SuppressLint +import android.os.Parcelable +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.navigation +import androidx.navigation.compose.rememberNavController +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import org.thoughtcrime.securesms.ui.NavigationAction +import org.thoughtcrime.securesms.ui.ObserveAsEvents +import org.thoughtcrime.securesms.ui.UINavigator +import org.thoughtcrime.securesms.ui.horizontalSlideComposable + +// Destinations +sealed interface DebugMenuDestination: Parcelable { + @Serializable + @Parcelize + data object DebugMenuHome: DebugMenuDestination + + @Serializable + @Parcelize + data object DebugMenuLogs: DebugMenuDestination + +} + +@Serializable object DebugMenuGraph + +@SuppressLint("RestrictedApi") +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun DebugMenuNavHost( + startDestination: DebugMenuDestination = DebugMenuDestination.DebugMenuHome, + onBack: () -> Unit +){ + val navController = rememberNavController() + val navigator: UINavigator = remember { + UINavigator() + } + + val handleBack: () -> Unit = { + if (navController.previousBackStackEntry != null) { + navController.navigateUp() + } else { + onBack() // Finish activity if at root + } + } + + + ObserveAsEvents(flow = navigator.navigationActions) { action -> + when (action) { + is NavigationAction.Navigate -> navController.navigate( + action.destination + ) { + action.navOptions(this) + } + + NavigationAction.NavigateUp -> handleBack() + + is NavigationAction.NavigateToIntent -> { + navController.context.startActivity(action.intent) + } + + else -> {} + } + } + + NavHost( + navController = navController, + startDestination = DebugMenuGraph + ) { + navigation(startDestination = startDestination) { + // Home + horizontalSlideComposable { entry -> + val viewModel = navController.debugGraphViewModel(entry, navigator) + + DebugMenuScreen( + viewModel = viewModel, + onBack = onBack + ) + } + + // Logs + horizontalSlideComposable { entry -> + val viewModel = navController.debugGraphViewModel(entry, navigator) + + DebugLogScreen( + viewModel = viewModel, + onBack = handleBack + ) + } + } + } +} + +@Composable +private fun NavController.debugGraphViewModel( + entry: androidx.navigation.NavBackStackEntry, + navigator: UINavigator +): DebugMenuViewModel { + val graphEntry = remember(entry) { getBackStackEntry(DebugMenuGraph) } + return hiltViewModel< + DebugMenuViewModel, + DebugMenuViewModel.Factory + >(graphEntry) { factory -> factory.create(navigator) } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuScreen.kt index 6c0f22805a..7162f89014 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuScreen.kt @@ -4,20 +4,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.lifecycle.viewmodel.compose.viewModel @Composable fun DebugMenuScreen( modifier: Modifier = Modifier, - debugMenuViewModel: DebugMenuViewModel = viewModel(), - onClose: () -> Unit + viewModel: DebugMenuViewModel, + onBack: () -> Unit ) { - val uiState by debugMenuViewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsState() DebugMenu( modifier = modifier, uiState = uiState, - sendCommand = debugMenuViewModel::onCommand, - onClose = onClose + sendCommand = viewModel::onCommand, + onClose = onBack ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt index b88a47d3ea..88f57881db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -5,22 +5,30 @@ import android.content.ClipboardManager import android.content.Context import android.os.Build import android.widget.Toast +import androidx.collection.ArraySet import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.ED25519 +import network.loki.messenger.libsession_util.PRIORITY_HIDDEN +import network.loki.messenger.libsession_util.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.protocol.ProFeature import network.loki.messenger.libsession_util.util.BlindKeyAPI +import network.loki.messenger.libsession_util.util.toBitSet import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.file_server.FileServer import org.session.libsession.messaging.file_server.FileServerApi @@ -28,28 +36,29 @@ import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.notifications.TokenFetcher import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState import org.session.libsession.utilities.Address -import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.Environment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.upsertContact +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.hexEncodedPublicKey -import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.database.RecipientSettingsDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.tokenpage.TokenPageNotificationManager +import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.util.ClearDataUtils +import org.thoughtcrime.securesms.util.DateUtils import java.time.ZonedDateTime -import javax.inject.Inject -@HiltViewModel -class DebugMenuViewModel @Inject constructor( +@HiltViewModel(assistedFactory = DebugMenuViewModel.Factory::class) +class DebugMenuViewModel @AssistedInject constructor( + @Assisted private val navigator: UINavigator, @param:ApplicationContext private val context: Context, private val textSecurePreferences: TextSecurePreferences, private val tokenPageNotificationManager: TokenPageNotificationManager, @@ -62,10 +71,18 @@ class DebugMenuViewModel @Inject constructor( private val conversationRepository: ConversationRepository, private val databaseInspector: DatabaseInspector, private val tokenFetcher: TokenFetcher, + private val debugLogger: DebugLogger, + private val dateUtils: DateUtils, + private val loginStateRepository: LoginStateRepository, subscriptionManagers: Set<@JvmSuppressWildcards SubscriptionManager>, ) : ViewModel() { private val TAG = "DebugMenu" + @AssistedFactory + interface Factory { + fun create(navigator: UINavigator): DebugMenuViewModel + } + private val _uiState = MutableStateFlow( UIState( currentEnvironment = textSecurePreferences.getEnvironment().label, @@ -92,21 +109,49 @@ class DebugMenuViewModel @Inject constructor( debugSubscriptionStatuses = setOf( DebugSubscriptionStatus.AUTO_GOOGLE, DebugSubscriptionStatus.EXPIRING_GOOGLE, + DebugSubscriptionStatus.EXPIRING_GOOGLE_LATER, DebugSubscriptionStatus.AUTO_APPLE, DebugSubscriptionStatus.EXPIRING_APPLE, DebugSubscriptionStatus.EXPIRED, + DebugSubscriptionStatus.EXPIRED_EARLIER, + DebugSubscriptionStatus.EXPIRED_APPLE, + DebugSubscriptionStatus.AUTO_APPLE_REFUNDING, ), selectedDebugSubscriptionStatus = textSecurePreferences.getDebugSubscriptionType() ?: DebugSubscriptionStatus.AUTO_GOOGLE, + debugProPlanStatus = setOf( + DebugProPlanStatus.NORMAL, + DebugProPlanStatus.LOADING, + DebugProPlanStatus.ERROR, + ), + selectedDebugProPlanStatus = textSecurePreferences.getDebugProPlanStatus() ?: DebugProPlanStatus.NORMAL, debugProPlans = subscriptionManagers.asSequence() .flatMap { it.availablePlans.asSequence().map { plan -> DebugProPlan(it, plan) } } .toList(), + forceNoBilling = textSecurePreferences.getDebugForceNoBilling(), + withinQuickRefund = textSecurePreferences.getDebugIsWithinQuickRefund(), availableAltFileServers = TEST_FILE_SERVERS, alternativeFileServer = textSecurePreferences.alternativeFileServer, + showToastForGroups = getDebugGroupToastPref(), + firstInstall = dateUtils.getLocaleFormattedDate( + context.packageManager.getPackageInfo(context.packageName, 0).firstInstallTime + ), + hasDonated = textSecurePreferences.hasDonated(), + hasCopiedDonationURL = textSecurePreferences.hasCopiedDonationURL(), + seenDonateCTAAmount = textSecurePreferences.seenDonationCTAAmount(), + lastSeenDonateCTA = if(textSecurePreferences.lastSeenDonationCTA() == 0L ) "Never" + else dateUtils.getLocaleFormattedDate(textSecurePreferences.lastSeenDonationCTA()), + showDonateCTAFromPositiveReview = textSecurePreferences.showDonationCTAFromPositiveReview(), + hasDonatedDebug = textSecurePreferences.hasDonatedDebug() ?: NOT_SET, + hasCopiedDonationURLDebug = textSecurePreferences.hasCopiedDonationURLDebug() ?: NOT_SET, + seenDonateCTAAmountDebug = textSecurePreferences.seenDonationCTAAmountDebug() ?: NOT_SET, + showDonateCTAFromPositiveReviewDebug = textSecurePreferences.showDonationCTAFromPositiveReviewDebug() ?: NOT_SET ) ) val uiState: StateFlow get() = _uiState + val debugLogs: Flow> get() = debugLogger.logSnapshots + init { if (databaseInspector.available) { viewModelScope.launch { @@ -159,7 +204,7 @@ class DebugMenuViewModel @Inject constructor( } is Commands.CopyAccountId -> { - val accountId = textSecurePreferences.getLocalNumber() + val accountId = loginStateRepository.requireLocalNumber() val clip = ClipData.newPlainText("Account ID", accountId) clipboardManager.setPrimaryClip(ClipData(clip)) @@ -173,6 +218,21 @@ class DebugMenuViewModel @Inject constructor( } } + is Commands.CopyProMasterKey -> { + val proKey = loginStateRepository.loggedInState.value?.seeded?.proMasterPrivateKey?.toHexString() + val clip = ClipData.newPlainText("Pro Master Key", proKey) + clipboardManager.setPrimaryClip(ClipData(clip)) + + // Show a toast if the version is below Android 13 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText( + context, + "Copied Pro Master Key to clipboard", + Toast.LENGTH_SHORT + ).show() + } + } + is Commands.HideMessageRequest -> { textSecurePreferences.setHasHiddenMessageRequests(command.hide) _uiState.value = _uiState.value.copy(hideMessageRequests = command.hide) @@ -227,13 +287,13 @@ class DebugMenuViewModel @Inject constructor( withContext(Dispatchers.Default) { val keys = List(command.count) { - KeyPairUtilities.generate() + AccountId(IdPrefix.STANDARD, ED25519.generate(null).secretKey.data) } configFactory.withMutableUserConfigs { configs -> for ((index, key) in keys.withIndex()) { configs.contacts.upsertContact( - key.x25519KeyPair.hexEncodedPublicKey.toAddress() as Address.Standard, + Address.Standard(key), ) { name = "${command.prefix}$index" approved = true @@ -268,6 +328,20 @@ class DebugMenuViewModel @Inject constructor( } } + is Commands.ForceNoBilling -> { + textSecurePreferences.setDebugForceNoBilling(command.set) + _uiState.update { + it.copy(forceNoBilling = command.set) + } + } + + is Commands.WithinQuickRefund -> { + textSecurePreferences.setDebugIsWithinQuickRefund(command.set) + _uiState.update { + it.copy(withinQuickRefund = command.set) + } + } + is Commands.ForcePostPro -> { textSecurePreferences.setForcePostPro(command.set) _uiState.update { @@ -283,7 +357,7 @@ class DebugMenuViewModel @Inject constructor( } is Commands.SetMessageProFeature -> { - val features = _uiState.value.messageProFeature.toMutableSet() + val features = ArraySet(_uiState.value.messageProFeature) if(command.set) features.add(command.feature) else features.remove(command.feature) textSecurePreferences.setDebugMessageFeatures(features) _uiState.update { @@ -308,8 +382,17 @@ class DebugMenuViewModel @Inject constructor( } } + is Commands.SetDebugProPlanStatus -> { + textSecurePreferences.setDebugProPlanStatus(command.status) + _uiState.update { + it.copy(selectedDebugProPlanStatus = command.status) + } + } + is Commands.PurchaseDebugPlan -> { - command.plan.apply { manager.purchasePlan(plan) } + viewModelScope.launch { + command.plan.apply { manager.purchasePlan(plan) } + } } is Commands.ToggleDeterministicEncryption -> { @@ -334,9 +417,113 @@ class DebugMenuViewModel @Inject constructor( _uiState.update { it.copy(alternativeFileServer = command.fileServer) } textSecurePreferences.alternativeFileServer = command.fileServer } + + is Commands.NavigateTo -> { + viewModelScope.launch { + navigator.navigate(command.destination) + } + } + + is Commands.ToggleDebugLogGroup -> { + debugLogger.showGroupToast(command.group, command.showToast) + _uiState.update { + it.copy(showToastForGroups = getDebugGroupToastPref()) + } + } + + is Commands.ClearAllDebugLogs -> { + debugLogger.clearAll() + } + + is Commands.CopyAllLogs -> { + val logs = debugLogger.currentSnapshot().joinToString("\n\n") { + "${dateUtils.getLocaleFormattedTime(it.date.toEpochMilli())}: ${it.message}" + } + + val clip = ClipData.newPlainText("Debug Logs", logs) + clipboardManager.setPrimaryClip(ClipData(clip)) + + // Show a toast if the version is below Android 13 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText( + context, + "Copied Debug Logs to clipboard", + Toast.LENGTH_SHORT + ).show() + } + } + + is Commands.CopyLog -> { + val log = "${dateUtils.getLocaleFormattedTime(command.log.date.toEpochMilli())}: ${command.log.message}" + + val clip = ClipData.newPlainText("Debug Log", log) + clipboardManager.setPrimaryClip(ClipData(clip)) + + // Show a toast if the version is below Android 13 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText( + context, + "Copied Debug Log to clipboard", + Toast.LENGTH_SHORT + ).show() + } + } + + is Commands.SetDebugHasDonated -> { + _uiState.update { + it.copy(hasDonatedDebug = command.value) + } + + when(command.value){ + TRUE -> textSecurePreferences.setHasDonatedDebug(TRUE) + FALSE -> textSecurePreferences.setHasDonatedDebug(FALSE) + else -> textSecurePreferences.setHasDonatedDebug(null) + } + } + is Commands.SetDebugHasCopiedDonation -> { + _uiState.update { + it.copy(hasCopiedDonationURLDebug = command.value) + } + + when(command.value){ + TRUE -> textSecurePreferences.setHasCopiedDonationURLDebug(TRUE) + FALSE -> textSecurePreferences.setHasCopiedDonationURLDebug(FALSE) + else -> textSecurePreferences.setHasCopiedDonationURLDebug(null) + } + } + is Commands.SetDebugDonationCTAViews -> { + _uiState.update { + it.copy(seenDonateCTAAmountDebug = command.value) + } + + when(command.value){ + SEEN_1 -> textSecurePreferences.setSeenDonationCTAAmountDebug(SEEN_1) + SEEN_2 -> textSecurePreferences.setSeenDonationCTAAmountDebug(SEEN_2) + SEEN_3 -> textSecurePreferences.setSeenDonationCTAAmountDebug(SEEN_3) + SEEN_4 -> textSecurePreferences.setSeenDonationCTAAmountDebug(SEEN_4) + else -> textSecurePreferences.setSeenDonationCTAAmountDebug(null) + } + } + is Commands.SetDebugShowDonationFromReview -> { + _uiState.update { + it.copy(showDonateCTAFromPositiveReviewDebug = command.value) + } + + when(command.value){ + TRUE -> textSecurePreferences.setShowDonationCTAFromPositiveReviewDebug(TRUE) + FALSE -> textSecurePreferences.setShowDonationCTAFromPositiveReviewDebug(FALSE) + else -> textSecurePreferences.setShowDonationCTAFromPositiveReviewDebug(null) + } + } } } + private fun getDebugGroupToastPref(): Map { + return DebugLogGroup.entries.associate { group -> + group.label to debugLogger.getGroupToastPreference(group) + } + } + private fun showEnvironmentWarningDialog(environment: String) { if(environment == _uiState.value.currentEnvironment) return val env = Environment.entries.firstOrNull { it.label == environment } ?: return @@ -430,7 +617,7 @@ class DebugMenuViewModel @Inject constructor( val forceCurrentUserAsPro: Boolean, val forceOtherUsersAsPro: Boolean, val forceIncomingMessagesAsPro: Boolean, - val messageProFeature: Set, + val messageProFeature: Set, val forcePostPro: Boolean, val forceShortTTl: Boolean, val forceDeprecationState: LegacyGroupDeprecationManager.DeprecationState?, @@ -441,9 +628,24 @@ class DebugMenuViewModel @Inject constructor( val dbInspectorState: DatabaseInspectorState, val debugSubscriptionStatuses: Set, val selectedDebugSubscriptionStatus: DebugSubscriptionStatus, + val debugProPlanStatus: Set, + val selectedDebugProPlanStatus: DebugProPlanStatus, val debugProPlans: List, + val forceNoBilling: Boolean, + val withinQuickRefund: Boolean, val alternativeFileServer: FileServer? = null, val availableAltFileServers: List = emptyList(), + val showToastForGroups: Map = emptyMap(), + val firstInstall: String, + val hasDonated: Boolean, + val hasCopiedDonationURL: Boolean, + val seenDonateCTAAmount: Int, + val lastSeenDonateCTA: String, + val showDonateCTAFromPositiveReview: Boolean, + val hasDonatedDebug: String, + val hasCopiedDonationURLDebug: String, + val seenDonateCTAAmountDebug: String, + val showDonateCTAFromPositiveReviewDebug: String ) enum class DatabaseInspectorState { @@ -454,10 +656,20 @@ class DebugMenuViewModel @Inject constructor( enum class DebugSubscriptionStatus(val label: String) { AUTO_GOOGLE("Auto Renewing (Google, 3 months)"), - EXPIRING_GOOGLE("Expiring/Cancelled (Google, 12 months)"), + AUTO_APPLE_REFUNDING("Refunding (Apple, 3 months)"), + EXPIRING_GOOGLE("Expiring/Cancelled (Expires in 14 days, Google, 12 months)"), + EXPIRING_GOOGLE_LATER("Expiring/Cancelled (Expires in 40 days, Google, 12 months)"), AUTO_APPLE("Auto Renewing (Apple, 1 months)"), - EXPIRING_APPLE("Expiring/Cancelled (Apple, 1 months)"), - EXPIRED("Expired"), + EXPIRING_APPLE("Expiring/Cancelled (Expires in 14 days, Apple, 1 months)"), + EXPIRED("Expired (Expired 2 days ago, Google)"), + EXPIRED_EARLIER("Expired (Expired 60 days ago, Google)"), + EXPIRED_APPLE("Expired (Expired 2 days ago, Apple)"), + } + + enum class DebugProPlanStatus(val label: String){ + NORMAL("Normal State"), + LOADING("Always Loading"), + ERROR("Always Erroring out"), } sealed class Commands { @@ -467,14 +679,17 @@ class DebugMenuViewModel @Inject constructor( object ScheduleTokenNotification : Commands() object Copy07PrefixedBlindedPublicKey : Commands() object CopyAccountId : Commands() + object CopyProMasterKey : Commands() data class HideMessageRequest(val hide: Boolean) : Commands() data class HideNoteToSelf(val hide: Boolean) : Commands() data class ForceCurrentUserAsPro(val set: Boolean) : Commands() data class ForceOtherUsersAsPro(val set: Boolean) : Commands() data class ForceIncomingMessagesAsPro(val set: Boolean) : Commands() + data class ForceNoBilling(val set: Boolean) : Commands() + data class WithinQuickRefund(val set: Boolean) : Commands() data class ForcePostPro(val set: Boolean) : Commands() data class ForceShortTTl(val set: Boolean) : Commands() - data class SetMessageProFeature(val feature: ProStatusManager.MessageProFeature, val set: Boolean) : Commands() + data class SetMessageProFeature(val feature: ProFeature, val set: Boolean) : Commands() data class ShowDeprecationChangeDialog(val state: LegacyGroupDeprecationManager.DeprecationState?) : Commands() object HideDeprecationChangeDialog : Commands() object OverrideDeprecationState : Commands() @@ -484,11 +699,21 @@ class DebugMenuViewModel @Inject constructor( data class GenerateContacts(val prefix: String, val count: Int): Commands() data object ToggleDatabaseInspector : Commands() data class SetDebugSubscriptionStatus(val status: DebugSubscriptionStatus) : Commands() + data class SetDebugProPlanStatus(val status: DebugProPlanStatus) : Commands() data class PurchaseDebugPlan(val plan: DebugProPlan) : Commands() data object ToggleDeterministicEncryption : Commands() data object ToggleDebugAvatarReupload : Commands() data object ResetPushToken : Commands() data class SelectAltFileServer(val fileServer: FileServer?) : Commands() + data class NavigateTo(val destination: DebugMenuDestination) : Commands() + data class ToggleDebugLogGroup(val group: DebugLogGroup, val showToast: Boolean) : Commands() + data object ClearAllDebugLogs : Commands() + data object CopyAllLogs : Commands() + data class CopyLog(val log: DebugLogData) : Commands() + data class SetDebugHasDonated(val value: String) : Commands() + data class SetDebugHasCopiedDonation(val value: String) : Commands() + data class SetDebugDonationCTAViews(val value: String) : Commands() + data class SetDebugShowDonationFromReview(val value: String) : Commands() } companion object { @@ -502,5 +727,13 @@ class DebugMenuViewModel @Inject constructor( ed25519PublicKeyHex = "929e33ded05e653fec04b49645117f51851f102a947e04806791be416ed76602", ) ) + + val NOT_SET = "Not set" + val TRUE = "True" + val FALSE = "False" + val SEEN_1 = "1" + val SEEN_2 = "2" + val SEEN_3 = "3" + val SEEN_4 = "4" } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt index 8b421e3644..9bf4e762ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -26,11 +26,11 @@ import org.session.libsession.utilities.ConfigUpdateNotification import org.session.libsession.utilities.GroupConfigs import org.session.libsession.utilities.MutableGroupConfigs import org.session.libsession.utilities.MutableUserConfigs -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.UserConfigs import org.session.libsession.utilities.getGroup import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.configs.ConfigToDatabaseSync import org.thoughtcrime.securesms.database.ConfigDatabase import org.thoughtcrime.securesms.database.ConfigVariant @@ -46,7 +46,7 @@ import kotlin.concurrent.write class ConfigFactory @Inject constructor( private val configDatabase: ConfigDatabase, private val storage: Lazy, - private val textSecurePreferences: TextSecurePreferences, + private val loginStateRepository: LoginStateRepository, private val clock: SnodeClock, private val configToDatabaseSync: Lazy, @param:ManagerScope private val coroutineScope: CoroutineScope @@ -72,15 +72,12 @@ class ConfigFactory @Inject constructor( private val _configUpdateNotifications = MutableSharedFlow() override val configUpdateNotifications get() = _configUpdateNotifications - private fun requiresCurrentUserAccountId(): AccountId = - AccountId(requireNotNull(textSecurePreferences.getLocalNumber()) { - "No logged in user" - }) + private fun requiresCurrentUserAccountId() = loginStateRepository.requireLocalAccountId() private fun requiresCurrentUserED25519SecKey(): ByteArray = - requireNotNull(storage.get().getUserED25519KeyPair()?.secretKey?.data) { + requireNotNull(loginStateRepository.peekLoginState()) { "No logged in user" - } + }.accountEd25519KeyPair.secretKey.data private fun ensureUserConfigsInitialized(): Pair { val userAccountId = requiresCurrentUserAccountId() @@ -390,7 +387,7 @@ class ConfigFactory @Inject constructor( // We need to persist the data to the database to save timestamp after the push val userAccountId = requiresCurrentUserAccountId() for ((variant, data, timestamp) in dump) { - configDatabase.storeConfig(variant, userAccountId.hexString, data, timestamp) + configDatabase.storeConfig(variant, userAccountId.hexString, data, timestamp.toEpochMilli()) } } @@ -412,11 +409,11 @@ class ConfigFactory @Inject constructor( if (pendingConfig != null) { for (hash in hashes) { configs.groupKeys.loadKey( - pendingConfig, - hash, - timestamp, - configs.groupInfo.pointer, - configs.groupMembers.pointer + message = pendingConfig, + hash = hash, + timestampMs = timestamp.toEpochMilli(), + infoPtr = configs.groupInfo.pointer, + membersPtr = configs.groupMembers.pointer ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt index 08ec99a3d8..d4bd5bd54a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt @@ -30,7 +30,6 @@ interface DatabaseComponent { fun threadDatabase(): ThreadDatabase fun mmsSmsDatabase(): MmsSmsDatabase fun groupDatabase(): GroupDatabase - fun recipientDatabase(): RecipientDatabase fun lokiAPIDatabase(): LokiAPIDatabase fun lokiMessageDatabase(): LokiMessageDatabase fun reactionDatabase(): ReactionDatabase diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt index 862f5ea07c..1da24a0866 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt @@ -6,12 +6,10 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import org.session.libsession.database.MessageDataProvider -import org.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.crypto.AttachmentSecret import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider import org.thoughtcrime.securesms.database.AttachmentDatabase -import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase import org.thoughtcrime.securesms.database.ConfigDatabase import org.thoughtcrime.securesms.database.DraftDatabase import org.thoughtcrime.securesms.database.EmojiSearchDatabase @@ -22,14 +20,11 @@ import org.thoughtcrime.securesms.database.GroupReceiptDatabase import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiBackupFilesDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase -import org.thoughtcrime.securesms.database.LokiUserDatabase import org.thoughtcrime.securesms.database.MediaDatabase -import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.PushDatabase import org.thoughtcrime.securesms.database.ReactionDatabase import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.SearchDatabase -import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.migration.DatabaseMigrationManager import javax.inject.Provider @@ -59,25 +54,19 @@ object DatabaseModule { fun provideMediaDatbase(@ApplicationContext context: Context, openHelper: Provider) = MediaDatabase(context, openHelper) - @Provides - @Singleton - fun provideMmsSms(@ApplicationContext context: Context, openHelper: Provider) = MmsSmsDatabase(context, openHelper) - @Provides @Singleton fun provideDraftDatabase(@ApplicationContext context: Context, openHelper: Provider) = DraftDatabase(context, openHelper) - @Provides - @Singleton - fun providePushDatabase(@ApplicationContext context: Context, openHelper: Provider) = PushDatabase(context,openHelper) @Provides @Singleton - fun provideGroupDatabase(@ApplicationContext context: Context, openHelper: Provider) = GroupDatabase(context,openHelper) + fun providePushDatabase(@ApplicationContext context: Context, openHelper: Provider) = PushDatabase(context,openHelper) @Provides @Singleton - fun provideRecipientDatabase(@ApplicationContext context: Context, openHelper: Provider) = RecipientDatabase(context,openHelper) + fun provideGroupDatabase(@ApplicationContext context: Context, openHelper: Provider, loginStateRepository: LoginStateRepository) + = GroupDatabase(context,openHelper, loginStateRepository) @Provides @Singleton @@ -95,23 +84,12 @@ object DatabaseModule { @Singleton fun provideLokiMessageDatabase(@ApplicationContext context: Context, openHelper: Provider) = LokiMessageDatabase(context,openHelper) - @Provides - @Singleton - fun provideLokiUserDatabase(@ApplicationContext context: Context, openHelper: Provider) = LokiUserDatabase(context,openHelper) @Provides @Singleton fun provideLokiBackupFilesDatabase(@ApplicationContext context: Context, openHelper: Provider) = LokiBackupFilesDatabase(context,openHelper) - @Provides - @Singleton - fun provideSessionContactDatabase(@ApplicationContext context: Context, openHelper: Provider) = SessionContactDatabase(context,openHelper) - - @Provides - @Singleton - fun provideBlindedIdMappingDatabase(@ApplicationContext context: Context, openHelper: Provider) = BlindedIdMappingDatabase(context, openHelper) - @Provides @Singleton fun provideGroupMemberDatabase(@ApplicationContext context: Context, openHelper: Provider) = GroupMemberDatabase(context, openHelper) diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/RecipientAvatarDownloadManager.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/RecipientAvatarDownloadManager.kt index d702e57ef5..db88434446 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/RecipientAvatarDownloadManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/RecipientAvatarDownloadManager.kt @@ -7,8 +7,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.scan @@ -22,6 +20,7 @@ import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemote import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.attachments.AvatarDownloadManager +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ManagerScope import javax.inject.Inject @@ -35,23 +34,18 @@ class RecipientAvatarDownloadManager @Inject constructor( private val configFactory: ConfigFactory, @ManagerScope scope: CoroutineScope, private val avatarDownloadManager: AvatarDownloadManager, + private val loginStateRepository: LoginStateRepository, ) { private val avatarBulkDownloadSemaphore = Semaphore(5) init { scope.launch { - prefs.watchLocalNumber() - .map { it != null } - .flatMapLatest { isLoggedIn -> - if (isLoggedIn) { - (configFactory.configUpdateNotifications as Flow<*>) - .debounce(500) - .onStart { emit(Unit) } - .map { getAllAvatars() } - } else { - flowOf(emptySet()) - } - } + loginStateRepository.flowWithLoggedInState { + (configFactory.configUpdateNotifications as Flow<*>) + .debounce(500) + .onStart { emit(Unit) } + .map { getAllAvatars() } + } .scan(State()) { acc, newSet -> val toDownload = newSet - acc.downloadedAvatar val coroutineJobs = acc.downloadingJob.toMutableMap() diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt index 28671f9f19..fbdd608b25 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -26,9 +26,7 @@ import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigUpdateNotification import org.session.libsession.utilities.GroupDisplayInfo -import org.session.libsession.utilities.recipients.ProStatus import org.session.libsession.utilities.recipients.displayName -import org.session.libsession.utilities.recipients.shouldShowProBadge import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.util.AvatarUIData @@ -68,7 +66,7 @@ abstract class BaseGroupMembersViewModel( for ((member, status) in rawMembers) { memberState.add(createGroupMember( member = member, status = status, - proStatus = recipientRepository.getRecipient(member.accountId().toAddress()).proStatus, + shouldShowProBadge = recipientRepository.getRecipient(member.accountId().toAddress()).shouldShowProBadge, myAccountId = currentUserId, amIAdmin = displayInfo.isUserAdmin )) @@ -104,7 +102,7 @@ abstract class BaseGroupMembersViewModel( private suspend fun createGroupMember( member: GroupMember, status: GroupMember.Status, - proStatus: ProStatus, + shouldShowProBadge: Boolean, myAccountId: AccountId, amIAdmin: Boolean, ): GroupMemberState { @@ -137,7 +135,7 @@ abstract class BaseGroupMembersViewModel( status = status.takeIf { !isMyself }, // Status is only meant for other members highlightStatus = highlightStatus, showAsAdmin = member.isAdminOrBeingPromoted(status), - showProBadge = proStatus.shouldShowProBadge(), + showProBadge = shouldShowProBadge, avatarUIData = avatarUtils.getUIDataFromAccountId(memberAccountId.hexString), clickable = !isMyself, statusLabel = getMemberLabel(status, context, amIAdmin), diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt index 141cb8233b..476b70925d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt @@ -42,6 +42,7 @@ class GroupLeavingWorker @AssistedInject constructor( private val groupScope: GroupScope, private val tokenFetcher: TokenFetcher, private val pushRegistryV2: PushRegistryV2, + private val messageSender: MessageSender, ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { val groupId = requireNotNull(inputData.getString(KEY_GROUP_ID)) { @@ -98,7 +99,7 @@ class GroupLeavingWorker @AssistedInject constructor( val statusChannel = Channel>() // Always send a "XXX left" message to the group if we can - MessageSender.send( + messageSender.send( GroupUpdated( GroupUpdateMessage.newBuilder() .setMemberLeftNotificationMessage(DataMessage.GroupUpdateMemberLeftNotificationMessage.getDefaultInstance()) @@ -110,7 +111,7 @@ class GroupLeavingWorker @AssistedInject constructor( // If we are not the only admin, send a left message for other admin to handle the member removal // We'll have to wait for this message to be sent before going ahead to delete the group - MessageSender.send( + messageSender.send( GroupUpdated( GroupUpdateMessage.newBuilder() .setMemberLeftMessage(DataMessage.GroupUpdateMemberLeftMessage.getDefaultInstance()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index c5e3acf8dc..91a500b55c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -4,7 +4,6 @@ import android.content.Context import com.google.protobuf.ByteString import com.squareup.phrase.Phrase import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async @@ -13,7 +12,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import network.loki.messenger.R -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.Namespace import network.loki.messenger.libsession_util.util.Bytes.Companion.toBytes @@ -32,7 +31,6 @@ import org.session.libsession.messaging.jobs.InviteContactsJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.control.GroupUpdated -import org.session.libsession.messaging.messages.visible.Profile import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.MessageAuthentication.buildDeleteMemberContentSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeSignature @@ -63,6 +61,7 @@ import org.thoughtcrime.securesms.configs.ConfigUploader import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.dependencies.ConfigFactory @@ -84,10 +83,13 @@ class GroupManagerV2Impl @Inject constructor( private val clock: SnodeClock, private val messageDataProvider: MessageDataProvider, private val lokiAPIDatabase: LokiAPIDatabase, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, private val configUploader: ConfigUploader, private val scope: GroupScope, private val groupPollerManager: GroupPollerManager, private val recipientRepository: RecipientRepository, + private val messageSender: MessageSender, + private val inviteContactJobFactory: InviteContactsJob.Factory, ) : GroupManagerV2 { private val dispatcher = Dispatchers.Default @@ -117,7 +119,6 @@ class GroupManagerV2Impl @Inject constructor( ): Recipient = withContext(dispatcher) { val ourAccountId = requireNotNull(storage.getUserPublicKey()) { "Our account ID is not available" } - val ourProfile = storage.getUserProfile() val groupCreationTimestamp = clock.currentTimeMills() @@ -157,10 +158,14 @@ class GroupManagerV2Impl @Inject constructor( } // Add ourselves as admin + val (ourName, ourPic) = configFactory.withUserConfigs { configs -> + configs.userProfile.getName().orEmpty() to configs.userProfile.getPic() + } + newGroupConfigs.groupMembers.set( newGroupConfigs.groupMembers.getOrConstruct(ourAccountId).apply { - setName(ourProfile.displayName.orEmpty()) - setProfilePic(ourProfile.profilePicture ?: UserPic.DEFAULT) + setName(ourName) + setProfilePic(ourPic) setPromotionAccepted() } ) @@ -197,11 +202,11 @@ class GroupManagerV2Impl @Inject constructor( "Failed to create a thread for the group" } - val recipient = recipientRepository.getRecipient(Address.fromSerialized(groupId.hexString))!! + val recipient = recipientRepository.getRecipient(Address.fromSerialized(groupId.hexString)) // Invite members JobQueue.shared.add( - InviteContactsJob( + inviteContactJobFactory.create( groupSessionId = groupId.hexString, memberSessionIds = members.map { it.hexString }.toTypedArray() ) @@ -321,9 +326,9 @@ class GroupManagerV2Impl @Inject constructor( // Send the invitation message to the new members JobQueue.shared.add( - InviteContactsJob( - group.hexString, - newMembers.map { it.hexString }.toTypedArray() + inviteContactJobFactory.create( + groupSessionId = group.hexString, + memberSessionIds = newMembers.map { it.hexString }.toTypedArray() ) ) } @@ -355,7 +360,7 @@ class GroupManagerV2Impl @Inject constructor( storage.insertGroupInfoChange(updatedMessage, group) - MessageSender.send(updatedMessage, Address.fromSerialized(group.hexString)) + messageSender.send(updatedMessage, Address.fromSerialized(group.hexString)) } override suspend fun removeMembers( @@ -394,7 +399,7 @@ class GroupManagerV2Impl @Inject constructor( updateMessage ).apply { sentTimestamp = timestamp } - MessageSender.send(message, Address.fromSerialized(groupAccountId.hexString)) + messageSender.send(message, Address.fromSerialized(groupAccountId.hexString)) storage.insertGroupInfoChange(message, groupAccountId) } @@ -524,7 +529,7 @@ class GroupManagerV2Impl @Inject constructor( val promotionDeferred = members.associateWith { member -> async { // The promotion message shouldn't be persisted to avoid being retried automatically - MessageSender.sendNonDurably( + messageSender.sendNonDurably( message = promoteMessage, address = Address.fromSerialized(member.hexString), isSyncMessage = false, @@ -555,7 +560,7 @@ class GroupManagerV2Impl @Inject constructor( if (!isRepromote) { - MessageSender.sendAndAwait(message, Address.fromSerialized(group.hexString)) + messageSender.sendAndAwait(message, Address.fromSerialized(group.hexString)) } } } @@ -656,10 +661,10 @@ class GroupManagerV2Impl @Inject constructor( .setIsApproved(true) val responseData = GroupUpdateMessage.newBuilder() .setInviteResponse(inviteResponse) - val responseMessage = GroupUpdated(responseData.build(), profile = storage.getUserProfile()) + val responseMessage = GroupUpdated(responseData.build()) // this will fail the first couple of times :) runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( responseMessage, Destination.ClosedGroup(group.groupAccountId), isSyncMessage = false @@ -883,7 +888,7 @@ class GroupManagerV2Impl @Inject constructor( // Clear all polling states lokiAPIDatabase.clearLastMessageHashes(groupId.hexString) - lokiAPIDatabase.clearReceivedMessageHashValues(groupId.hexString) + receivedMessageHashDatabase.removeAllByPublicKey(groupId.hexString) SessionMetaProtocol.clearReceivedMessages() configFactory.deleteGroupConfigs(groupId) @@ -926,7 +931,7 @@ class GroupManagerV2Impl @Inject constructor( } storage.insertGroupInfoChange(message, groupId) - MessageSender.sendAndAwait(message, Address.fromSerialized(groupId.hexString)) + messageSender.sendAndAwait(message, Address.fromSerialized(groupId.hexString)) } override suspend fun setDescription(groupId: AccountId, newDescription: String): Unit = @@ -1008,7 +1013,7 @@ class GroupManagerV2Impl @Inject constructor( sentTimestamp = timestamp } - MessageSender.sendAndAwait(message, Address.fromSerialized(groupId.hexString)) + messageSender.sendAndAwait(message, Address.fromSerialized(groupId.hexString)) } override suspend fun handleDeleteMemberContent( @@ -1141,7 +1146,7 @@ class GroupManagerV2Impl @Inject constructor( sentTimestamp = timestamp } - MessageSender.send(message, Address.fromSerialized(groupId.hexString)) + messageSender.send(message, Address.fromSerialized(groupId.hexString)) storage.deleteGroupInfoMessages(groupId, UpdateMessageData.Kind.GroupExpirationUpdated::class.java) storage.insertGroupInfoChange(message, groupId) @@ -1192,15 +1197,4 @@ class GroupManagerV2Impl @Inject constructor( val firstError = this.results.firstOrNull { it.code != 200 } require(firstError == null) { "$errorMessage: ${firstError!!.body}" } } - - private val Profile.profilePicture: UserPic? - get() { - val url = this.profilePictureURL - val key = this.profileKey - return if (url != null && key != null) { - UserPic(url, key) - } else { - null - } - } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersActivity.kt index 07e7ff27c0..e7544455c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersActivity.kt @@ -4,7 +4,7 @@ import android.content.Context import android.content.Intent import androidx.compose.runtime.Composable import androidx.core.content.IntentCompat -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import dagger.hilt.android.AndroidEntryPoint import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.FullComposeScreenLockActivity diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt index f6c44c9a9f..76954f62e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt @@ -6,6 +6,7 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.delay @@ -19,15 +20,13 @@ import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import network.loki.messenger.libsession_util.Namespace -import org.session.libsession.messaging.jobs.BatchMessageReceiveJob -import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.jobs.MessageReceiveParameters -import org.session.libsession.messaging.messages.Destination -import org.session.libsession.snode.RawResponse +import org.session.libsession.messaging.sending_receiving.MessageParser +import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeClock import org.session.libsession.snode.model.BatchResponse import org.session.libsession.snode.model.RetrieveMessageResponse +import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigMessage import org.session.libsession.utilities.getGroup @@ -36,6 +35,7 @@ import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.util.AppVisibilityManager import org.thoughtcrime.securesms.util.getRootCause import java.time.Instant @@ -51,7 +51,9 @@ class GroupPoller @AssistedInject constructor( private val clock: SnodeClock, private val appVisibilityManager: AppVisibilityManager, private val groupRevokedMessageHandler: GroupRevokedMessageHandler, - private val batchMessageReceiveJobFactory: BatchMessageReceiveJob.Factory, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, + private val messageParser: MessageParser, + private val receivedMessageProcessor: ReceivedMessageProcessor, ) { companion object { private const val POLL_INTERVAL = 3_000L @@ -254,8 +256,8 @@ class GroupPoller @AssistedInject constructor( namespace = Namespace.REVOKED_GROUP_MESSAGES(), maxSize = null, ), - RetrieveMessageResponse::class.java - ).messages.filterNotNull() + RetrieveMessageResponse.serializer() + ).messages } if (configHashesToExtends.isNotEmpty() && adminKey != null) { @@ -290,7 +292,7 @@ class GroupPoller @AssistedInject constructor( namespace = Namespace.GROUP_MESSAGES(), maxSize = null, ), - responseType = Map::class.java + responseType = RetrieveMessageResponse.serializer() ) } @@ -313,8 +315,8 @@ class GroupPoller @AssistedInject constructor( namespace = ns, maxSize = null, ), - responseType = RetrieveMessageResponse::class.java - ).messages.filterNotNull() + responseType = RetrieveMessageResponse.serializer() + ).messages } } @@ -323,7 +325,7 @@ class GroupPoller @AssistedInject constructor( // must be processed first. pollingTasks += "polling and handling group config keys and messages" to async { val result = runCatching { - val (keysMessage, infoMessage, membersMessage) = groupConfigRetrieval.map { it.await() } + val (keysMessage, infoMessage, membersMessage) = groupConfigRetrieval.awaitAll() handleGroupConfigMessages(keysMessage, infoMessage, membersMessage) saveLastMessageHash(snode, keysMessage, Namespace.GROUP_KEYS()) saveLastMessageHash(snode, infoMessage, Namespace.GROUP_INFO()) @@ -334,7 +336,16 @@ class GroupPoller @AssistedInject constructor( } val regularMessages = groupMessageRetrieval.await() - handleMessages(regularMessages, snode) + handleMessages(regularMessages.messages) + + regularMessages.messages.maxByOrNull { it.timestamp }?.let { newest -> + lokiApiDatabase.setLastMessageHashValue( + snode = snode, + publicKey = groupId.hexString, + newValue = newest.hash, + namespace = Namespace.GROUP_MESSAGES() + ) + } } // Revoke message must be handled regardless, and at the end @@ -381,7 +392,7 @@ class GroupPoller @AssistedInject constructor( if (badResponse != null) { Log.e(TAG, "Group polling failed due to a server error", badResponse) - pollState.swarmNodes -= currentSnode!! + pollState.swarmNodes -= currentSnode } } } @@ -397,7 +408,7 @@ class GroupPoller @AssistedInject constructor( } private fun RetrieveMessageResponse.Message.toConfigMessage(): ConfigMessage { - return ConfigMessage(hash, data, timestamp ?: clock.currentTimeMills()) + return ConfigMessage(hash, data, timestamp.toEpochMilli()) } private fun saveLastMessageHash( @@ -443,38 +454,47 @@ class GroupPoller @AssistedInject constructor( ) } - private fun handleMessages(body: RawResponse, snode: Snode) { - val messages = configFactoryProtocol.withGroupConfigs(groupId) { - SnodeAPI.parseRawMessagesResponse( - rawResponse = body, - snode = snode, - publicKey = groupId.hexString, - decrypt = { data -> - val (decrypted, sender) = it.groupKeys.decrypt(data) ?: return@parseRawMessagesResponse null - decrypted to AccountId(sender) - }, - namespace = Namespace.GROUP_MESSAGES(), - ) + private fun handleMessages(messages: List) { + if (messages.isEmpty()) { + return } - val parameters = messages.map { (envelope, serverHash) -> - MessageReceiveParameters( - envelope.toByteArray(), - serverHash = serverHash, - closedGroup = Destination.ClosedGroup(groupId.hexString) - ) - } + val start = System.currentTimeMillis() + val threadAddress = Address.Group(groupId) + + receivedMessageProcessor.startProcessing("GroupPoller($groupId)") { ctx -> + for (message in messages) { + if (receivedMessageHashDatabase.checkOrUpdateDuplicateState( + swarmPublicKey = groupId.hexString, + namespace = Namespace.GROUP_MESSAGES(), + hash = message.hash + )) { + Log.v(TAG, "Skipping duplicated group message ${message.hash} for group $groupId") + continue + } - parameters.chunked(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { chunk -> - JobQueue.shared.add(batchMessageReceiveJobFactory.create( - messages = chunk, - fromCommunity = null - )) - } + try { + val (msg, proto) = messageParser.parseGroupMessage( + data = message.data, + serverHash = message.hash, + groupId = groupId, + currentUserId = ctx.currentUserId, + currentUserEd25519PrivKey = ctx.currentUserEd25519KeyPair.secretKey.data, + ) - if (messages.isNotEmpty()) { - Log.d(TAG, "Received and handled ${messages.size} group messages") + receivedMessageProcessor.processSwarmMessage( + threadAddress = threadAddress, + message = msg, + proto = proto, + context = ctx, + ) + } catch (e: Exception) { + Log.e(TAG, "Error handling group message", e) + } + } } + + Log.d(TAG, "Handled ${messages.size} group messages for $groupId in ${System.currentTimeMillis() - start}ms") } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPollerManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPollerManager.kt index 4cc652f628..001d5e3756 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPollerManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPollerManager.kt @@ -23,11 +23,11 @@ import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.sync.Semaphore -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.userConfigsChanged import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent @@ -53,9 +53,9 @@ import javax.inject.Singleton @Singleton class GroupPollerManager @Inject constructor( configFactory: ConfigFactory, - preferences: TextSecurePreferences, connectivity: NetworkConnectivity, pollFactory: GroupPoller.Factory, + loginStateRepository: LoginStateRepository, @param:ManagerScope private val managerScope: CoroutineScope, ) : OnAppStartupComponent { private val groupPollerSemaphore = Semaphore(20) @@ -64,10 +64,10 @@ class GroupPollerManager @Inject constructor( private val groupPollers: StateFlow> = combine( connectivity.networkAvailable.debounce(200L), - preferences.watchLocalNumber() - ) { networkAvailable, localNumber -> - Log.v(TAG, "Network available: $networkAvailable, hasLocalNumber: ${localNumber != null}") - networkAvailable && localNumber != null + loginStateRepository.loggedInState, + ) { networkAvailable, loginState -> + Log.v(TAG, "Network available: $networkAvailable, hasLogin: ${loginState != null}") + networkAvailable && loginState != null } // This flatMap produces a flow of groups that should be polled now .flatMapLatest { shouldPoll -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt index 5784b8a723..5ae4e18812 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt @@ -1,11 +1,13 @@ package org.thoughtcrime.securesms.groups +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -15,18 +17,21 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext +import network.loki.messenger.R import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient -import org.session.libsession.utilities.recipients.shouldShowProBadge import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.home.search.searchName import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUtils @@ -39,6 +44,7 @@ open class SelectContactsViewModel @AssistedInject constructor( @Assisted private val excludingAccountIDs: Set
, @Assisted private val contactFiltering: (Recipient) -> Boolean, // default will filter out blocked and unapproved contacts private val recipientRepository: RecipientRepository, + @param:ApplicationContext private val context: Context, ) : ViewModel() { // Input: The search query private val mutableSearchQuery = MutableStateFlow("") @@ -71,6 +77,27 @@ open class SelectContactsViewModel @AssistedInject constructor( val currentSelected: Set
get() = mutableSelectedContactAccountIDs.value + private val footerCollapsed = MutableStateFlow(false) + + val collapsibleFooterState: StateFlow = + combine(mutableSelectedContactAccountIDs, footerCollapsed) { selected, isCollapsed -> + val count = selected.size + val visible = count > 0 + val title = if (count == 0) GetString("") + else GetString( + context.resources.getQuantityString(R.plurals.contactSelected, count, count) + ) + + CollapsibleFooterState( + visible = visible, + // auto-expand when nothing is selected, otherwise keep user's choice + collapsed = if (!visible) false else isCollapsed, + footerActionTitle = title + ) + } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.Eagerly, CollapsibleFooterState()) + @OptIn(ExperimentalCoroutinesApi::class) private fun observeContacts() = (configFactory.configUpdateNotifications as Flow) .debounce(100L) @@ -112,7 +139,7 @@ open class SelectContactsViewModel @AssistedInject constructor( address = contact.address, avatarUIData = avatarData, selected = selectedAccountIDs.contains(contact.address), - showProBadge = contact.proStatus.shouldShowProBadge() + showProBadge = contact.shouldShowProBadge ) ) } @@ -144,6 +171,16 @@ open class SelectContactsViewModel @AssistedInject constructor( mutableSelectedContactAccountIDs.value = emptySet() } + fun toggleFooter() { + footerCollapsed.update { !it } + } + + data class CollapsibleFooterState( + val visible: Boolean = false, + val collapsed: Boolean = false, + val footerActionTitle : GetString = GetString("") + ) + @AssistedFactory interface Factory { fun create( diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt index 902c0bc4fe..b0ece45a94 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt @@ -4,10 +4,16 @@ 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.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api @@ -15,8 +21,10 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import network.loki.messenger.R @@ -24,9 +32,12 @@ import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.groups.ContactItem import org.thoughtcrime.securesms.groups.SelectContactsViewModel import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox +import org.thoughtcrime.securesms.ui.CollapsibleFooterAction +import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData +import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData +import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.SearchBar import org.thoughtcrime.securesms.ui.components.BackAppBar -import org.thoughtcrime.securesms.ui.components.AccentOutlineButton import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -42,17 +53,23 @@ fun InviteContactsScreen( viewModel: SelectContactsViewModel, onDoneClicked: () -> Unit, onBack: () -> Unit, - banner: @Composable ()->Unit = {} + banner: @Composable () -> Unit = {} ) { + val footerData by viewModel.collapsibleFooterState.collectAsState() + InviteContacts( contacts = viewModel.contacts.collectAsState().value, onContactItemClicked = viewModel::onContactItemClicked, searchQuery = viewModel.searchQuery.collectAsState().value, onSearchQueryChanged = viewModel::onSearchQueryChanged, - onSearchQueryClear = {viewModel.onSearchQueryChanged("") }, + onSearchQueryClear = { viewModel.onSearchQueryChanged("") }, onDoneClicked = onDoneClicked, onBack = onBack, - banner = banner + banner = banner, + data = footerData, + onToggleFooter = viewModel::toggleFooter, + onCloseFooter = viewModel::clearSelection + ) } @@ -66,15 +83,48 @@ fun InviteContacts( onSearchQueryClear: () -> Unit, onDoneClicked: () -> Unit, onBack: () -> Unit, - banner: @Composable ()->Unit = {} + banner: @Composable () -> Unit = {}, + data: SelectContactsViewModel.CollapsibleFooterState, + onToggleFooter: () -> Unit, + onCloseFooter: () -> Unit, ) { + val colors = LocalColors.current + val trayItems = listOf( + CollapsibleFooterItemData( + label = GetString(LocalResources.current.getString(R.string.membersInvite)), + buttonLabel = GetString(LocalResources.current.getString(R.string.membersInviteTitle)), + buttonColor = colors.accent, + onClick = { onDoneClicked() } + ) + ) + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, topBar = { BackAppBar( title = stringResource(id = R.string.membersInvite), onBack = onBack, ) }, + bottomBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) + .imePadding() + ) { + CollapsibleFooterAction( + data = CollapsibleFooterActionData( + title = data.footerActionTitle, + collapsed = data.collapsed, + visible = data.visible, + items = trayItems + ), + onCollapsedClicked = onToggleFooter, + onClosedClicked = onCloseFooter + ) + } + } ) { paddings -> Column( modifier = Modifier @@ -100,18 +150,19 @@ fun InviteContacts( Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - BottomFadingEdgeBox(modifier = Modifier.weight(1f)) { bottomContentPadding -> - if(contacts.isEmpty() && searchQuery.isEmpty()){ + Box(modifier = Modifier.weight(1f)) { + if (contacts.isEmpty() && searchQuery.isEmpty()) { Text( text = stringResource(id = R.string.contactNone), - modifier = Modifier.padding(top = LocalDimensions.current.spacing) + modifier = Modifier + .padding(top = LocalDimensions.current.spacing) .align(Alignment.TopCenter), style = LocalType.current.base.copy(color = LocalColors.current.textSecondary) ) } else { LazyColumn( state = scrollState, - contentPadding = PaddingValues(bottom = bottomContentPadding), + contentPadding = PaddingValues(bottom = LocalDimensions.current.spacing), ) { multiSelectMemberList( contacts = contacts, @@ -120,27 +171,7 @@ fun InviteContacts( } } } - - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth() - ) { - AccentOutlineButton( - onClick = onDoneClicked, - enabled = contacts.any { it.selected }, - modifier = Modifier - .padding(vertical = LocalDimensions.current.spacing) - .qaTag(R.string.qa_invite_button), - ) { - Text( - stringResource(id = R.string.membersInviteTitle) - ) - } - } } - } } @@ -174,6 +205,13 @@ private fun PreviewSelectContacts() { onSearchQueryClear = {}, onDoneClicked = {}, onBack = {}, + data = SelectContactsViewModel.CollapsibleFooterState( + collapsed = false, + visible = true, + footerActionTitle = GetString("1 Contact Selected") + ), + onToggleFooter = { }, + onCloseFooter = { }, ) } } @@ -192,7 +230,13 @@ private fun PreviewSelectEmptyContacts() { onSearchQueryClear = {}, onDoneClicked = {}, onBack = {}, - banner = { GroupMinimumVersionBanner() } + data = SelectContactsViewModel.CollapsibleFooterState( + collapsed = true, + visible = false, + footerActionTitle = GetString("") + ), + onToggleFooter = { }, + onCloseFooter = { } ) } } @@ -211,7 +255,13 @@ private fun PreviewSelectEmptyContactsWithSearch() { onSearchQueryClear = {}, onDoneClicked = {}, onBack = {}, - banner = { GroupMinimumVersionBanner() } + data = SelectContactsViewModel.CollapsibleFooterState( + collapsed = true, + visible = false, + footerActionTitle = GetString("") + ), + onToggleFooter = { }, + onCloseFooter = { } ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/AdminStateSync.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/AdminStateSync.kt index 16ded707ef..169321ce09 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/AdminStateSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/AdminStateSync.kt @@ -6,10 +6,10 @@ import kotlinx.coroutines.launch import network.loki.messenger.libsession_util.util.GroupInfo import network.loki.messenger.libsession_util.util.GroupMember import org.session.libsession.utilities.ConfigFactoryProtocol -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.userConfigsChanged import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import java.util.EnumSet @@ -26,7 +26,7 @@ import javax.inject.Singleton @Singleton class AdminStateSync @Inject constructor( private val configFactory: ConfigFactoryProtocol, - private val preferences: TextSecurePreferences, + private val loginStateRepository: LoginStateRepository, @param:ManagerScope private val scope: CoroutineScope ) : OnAppStartupComponent { private var job: Job? = null @@ -35,37 +35,38 @@ class AdminStateSync @Inject constructor( require(job == null) { "Already started" } job = scope.launch { - configFactory.userConfigsChanged(onlyConfigTypes = setOf(UserConfigType.USER_GROUPS)) - .collect { - val localNumber = preferences.getLocalNumber() ?: return@collect + loginStateRepository.flowWithLoggedInState { + configFactory.userConfigsChanged(onlyConfigTypes = setOf(UserConfigType.USER_GROUPS)) + }.collect { + val localNumber = loginStateRepository.requireLocalNumber() - // Go through evey user groups and if we are admin of any of the groups, - // make sure we mark any pending group promotion status as "accepted" + // Go through evey user groups and if we are admin of any of the groups, + // make sure we mark any pending group promotion status as "accepted" - val allAdminGroups = configFactory.withUserConfigs { configs -> - configs.userGroups.all() - .asSequence() - .mapNotNull { - if ((it as? GroupInfo.ClosedGroupInfo)?.hasAdminKey() == true) { - AccountId(it.groupAccountId) - } else { - null - } + val allAdminGroups = configFactory.withUserConfigs { configs -> + configs.userGroups.all() + .asSequence() + .mapNotNull { + if ((it as? GroupInfo.ClosedGroupInfo)?.hasAdminKey() == true) { + AccountId(it.groupAccountId) + } else { + null } - } + } + } - val groupToMarkAccepted = allAdminGroups - .filter { groupId -> isMemberPromotionPending(groupId, localNumber) } + val groupToMarkAccepted = allAdminGroups + .filter { groupId -> isMemberPromotionPending(groupId, localNumber) } - for (groupId in groupToMarkAccepted) { - configFactory.withMutableGroupConfigs(groupId) { groupConfigs -> - groupConfigs.groupMembers.get(localNumber)?.let { member -> - member.setPromotionAccepted() - groupConfigs.groupMembers.set(member) - } + for (groupId in groupToMarkAccepted) { + configFactory.withMutableGroupConfigs(groupId) { groupConfigs -> + groupConfigs.groupMembers.get(localNumber)?.let { member -> + member.setPromotionAccepted() + groupConfigs.groupMembers.set(member) } } } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/CleanupInvitationHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/CleanupInvitationHandler.kt index 952421e504..5ae78bff80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/CleanupInvitationHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/CleanupInvitationHandler.kt @@ -7,8 +7,8 @@ import network.loki.messenger.libsession_util.allWithStatus import network.loki.messenger.libsession_util.util.GroupMember import org.session.libsession.messaging.groups.GroupScope import org.session.libsession.utilities.ConfigFactoryProtocol -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import javax.inject.Inject @@ -23,15 +23,15 @@ import javax.inject.Inject * after the app is started, and only done once on every app process. */ class CleanupInvitationHandler @Inject constructor( - private val prefs: TextSecurePreferences, private val configFactory: ConfigFactoryProtocol, private val groupScope: GroupScope, + private val loginStateRepository: LoginStateRepository, @param:ManagerScope private val scope: CoroutineScope ) : OnAppStartupComponent { override fun onPostAppStarted() { scope.launch { // Wait for the local number to be available - prefs.watchLocalNumber().first { it != null } + loginStateRepository.loggedInState.first { it != null } val allGroups = configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt index c118a0983a..0b9272a3df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt @@ -6,9 +6,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.libsession_util.ED25519 @@ -32,13 +30,13 @@ import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigUpdateNotification -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.waitUntilGroupConfigsPushed import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import javax.inject.Inject @@ -56,24 +54,17 @@ private const val TAG = "RemoveGroupMemberHandler" class RemoveGroupMemberHandler @Inject constructor( @param:ApplicationContext private val context: Context, private val configFactory: ConfigFactoryProtocol, - private val textSecurePreferences: TextSecurePreferences, private val clock: SnodeClock, private val messageDataProvider: MessageDataProvider, private val storage: StorageProtocol, private val groupScope: GroupScope, @ManagerScope scope: CoroutineScope, + private val messageSender: MessageSender, + private val loginStateRepository: LoginStateRepository, ) : OnAppStartupComponent { init { scope.launch { - textSecurePreferences - .watchLocalNumber() - .flatMapLatest { localNumber -> - if (localNumber == null) { - return@flatMapLatest emptyFlow() - } - - configFactory.configUpdateNotifications - } + loginStateRepository.flowWithLoggedInState { configFactory.configUpdateNotifications } .filterIsInstance() .collect { update -> val adminKey = configFactory.getGroup(update.groupId)?.adminKey?.data @@ -220,7 +211,7 @@ class RemoveGroupMemberHandler @Inject constructor( ): SnodeMessage { val timestamp = clock.currentTimeMills() - return MessageSender.buildWrappedMessageToSnode( + return messageSender.buildWrappedMessageToSnode( destination = Destination.ClosedGroup(groupAccountId), message = GroupUpdated( SignalServiceProtos.DataMessage.GroupUpdateMessage.newBuilder() diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt index 6a83b94cb4..64ba334ad7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -17,6 +17,7 @@ import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.model.NotifyType import org.thoughtcrime.securesms.database.model.ThreadRecord @@ -38,7 +39,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto @Inject lateinit var deprecationManager: LegacyGroupDeprecationManager @Inject lateinit var groupDatabase: GroupDatabase - @Inject lateinit var textSecurePreferences: TextSecurePreferences + @Inject lateinit var loginStateRepository: LoginStateRepository var onViewDetailsTapped: (() -> Unit?)? = null var onCopyConversationId: (() -> Unit?)? = null @@ -127,7 +128,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto val group = groupDatabase.getGroup(recipient.address.toString()).orNull() val isGroupAdmin = group.admins.map { it.toString() } - .contains(textSecurePreferences.getLocalNumber()) + .contains(loginStateRepository.requireLocalNumber()) if (isGroupAdmin) { text = context.getString(R.string.delete) 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 5d580c3d81..b571d0828d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -15,12 +15,10 @@ import network.loki.messenger.databinding.ViewConversationBinding import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.displayName -import org.session.libsession.utilities.recipients.shouldShowProBadge import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.model.NotifyType import org.thoughtcrime.securesms.database.model.ThreadRecord -import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.ui.components.Avatar import org.thoughtcrime.securesms.ui.setThemedContent @@ -88,7 +86,7 @@ class ConversationView : LinearLayout { // Thread name and pro badge binding.conversationViewDisplayName.text = senderDisplayName - binding.iconPro.isVisible = thread.recipient.proStatus.shouldShowProBadge() + binding.iconPro.isVisible = thread.recipient.shouldShowProBadge && !thread.recipient.isLocalNumber binding.timestampTextView.text = thread.date.takeIf { it != 0L }?.let { dateUtils.getDisplayFormattedTimeSpanString( 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 c200357545..4ebfc1c1e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -8,7 +8,6 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle -import android.view.ViewGroup.MarginLayoutParams import android.widget.Toast import androidx.activity.viewModels import androidx.compose.foundation.clickable @@ -18,10 +17,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible -import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager @@ -40,24 +41,22 @@ import kotlinx.coroutines.withContext import network.loki.messenger.BuildConfig import network.loki.messenger.R import network.loki.messenger.databinding.ActivityHomeBinding -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN +import network.loki.messenger.libsession_util.PRIORITY_HIDDEN import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier import org.session.libsession.snode.SnodeClock import org.session.libsession.utilities.Address -import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.RecipientData import org.session.libsession.utilities.recipients.displayName -import org.session.libsession.utilities.recipients.shouldShowProBadge import org.session.libsession.utilities.updateContact import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ScreenLockActionBarActivity +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsActivity import org.thoughtcrime.securesms.crypto.IdentityKeyUtil @@ -74,10 +73,10 @@ import org.thoughtcrime.securesms.home.search.GlobalSearchInputLayout import org.thoughtcrime.securesms.home.search.GlobalSearchResult import org.thoughtcrime.securesms.home.search.GlobalSearchViewModel import org.thoughtcrime.securesms.home.search.SearchContactActionBottomSheet -import org.thoughtcrime.securesms.home.startconversation.StartConversationDestination import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.preferences.SettingsActivity +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsActivity import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity import org.thoughtcrime.securesms.reviews.StoreReviewManager @@ -85,12 +84,12 @@ import org.thoughtcrime.securesms.reviews.ui.InAppReview import org.thoughtcrime.securesms.reviews.ui.InAppReviewViewModel import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.tokenpage.TokenPageNotificationManager -import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.ui.components.Avatar import org.thoughtcrime.securesms.ui.setThemedContent import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.util.AvatarUtils import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.applySafeInsetsMargins import org.thoughtcrime.securesms.util.applySafeInsetsPaddings import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.fadeIn @@ -133,13 +132,13 @@ class HomeActivity : ScreenLockActionBarActivity(), @Inject lateinit var proStatusManager: ProStatusManager @Inject lateinit var recipientRepository: RecipientRepository @Inject lateinit var avatarUtils: AvatarUtils - @Inject lateinit var startConversationNavigator: UINavigator + @Inject lateinit var loginStateRepository: LoginStateRepository private val globalSearchViewModel by viewModels() private val homeViewModel by viewModels() private val inAppReviewViewModel by viewModels() - private val publicKey: String by lazy { textSecurePreferences.getLocalNumber()!! } + private val publicKey: String by lazy { loginStateRepository.requireLocalNumber() } private val homeAdapter: HomeAdapter by lazy { HomeAdapter(context = this, configFactory = configFactory, listener = this, ::showMessageRequests, ::hideMessageRequests) @@ -239,6 +238,23 @@ class HomeActivity : ScreenLockActionBarActivity(), } } + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + homeViewModel.uiEvents.collect { event -> + when (event) { + is HomeViewModel.UiEvent.OpenProSettings -> { + startActivity( + ProSettingsActivity.createIntent( + this@HomeActivity, + event.start + ) + ) + } + } + } + } + } + // Set up seed reminder view lifecycleScope.launchWhenStarted { binding.seedReminderView.setThemedContent { @@ -272,10 +288,9 @@ class HomeActivity : ScreenLockActionBarActivity(), binding.dialogs.apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setThemedContent { - val dialogsState by homeViewModel.dialogsState.collectAsState() + val dialogsState by homeViewModel.dialogsState.collectAsStateWithLifecycle() HomeDialogs( dialogsState = dialogsState, - startConversationNavigator = startConversationNavigator, sendCommand = homeViewModel::onCommand ) } @@ -317,7 +332,7 @@ class HomeActivity : ScreenLockActionBarActivity(), launch(Dispatchers.Default) { // update things based on TextSecurePrefs (profile info etc) // Set up remaining components if needed - if (textSecurePreferences.getLocalNumber() != null) { + if (loginStateRepository.getLocalNumber() != null) { JobQueue.shared.resumePendingJobs() } } @@ -398,16 +413,6 @@ class HomeActivity : ScreenLockActionBarActivity(), } } - binding.root.applySafeInsetsPaddings( - applyBottom = false, - alsoApply = { insets -> - binding.globalSearchRecycler.updatePadding(bottom = insets.bottom) - binding.newConversationButton.updateLayoutParams { - bottomMargin = insets.bottom + resources.getDimensionPixelSize(R.dimen.new_conversation_button_bottom_offset) - } - } - ) - // Set up in-app review binding.inAppReviewView.setThemedContent { InAppReview( @@ -416,6 +421,8 @@ class HomeActivity : ScreenLockActionBarActivity(), sendCommands = inAppReviewViewModel::sendUiCommand, ) } + + applyViewInsets() } override fun onCancelClicked() { @@ -467,7 +474,7 @@ class HomeActivity : ScreenLockActionBarActivity(), GlobalSearchAdapter.Model.Contact( contact = it.value, isSelf = it.value.isSelf, - showProBadge = it.value.proStatus.shouldShowProBadge() + showProBadge = it.value.shouldShowProBadge ) } } @@ -477,10 +484,11 @@ class HomeActivity : ScreenLockActionBarActivity(), contacts.map { GlobalSearchAdapter.Model.Contact( contact = it, isSelf = it.isSelf, - showProBadge = it.proStatus.shouldShowProBadge() + showProBadge = it.shouldShowProBadge ) } + threads.mapNotNull { - if(it.address is Address.GroupLike) GlobalSearchAdapter.Model.GroupConversation(it) + if(it.address is Address.GroupLike) + GlobalSearchAdapter.Model.GroupConversation(it) else null } @@ -494,7 +502,7 @@ class HomeActivity : ScreenLockActionBarActivity(), messageResult = it, unread = unreadThreadMap[it.threadId] ?: 0, isSelf = it.conversationRecipient.isLocalNumber, - showProBadge = it.conversationRecipient.proStatus.shouldShowProBadge() + showProBadge = it.conversationRecipient.shouldShowProBadge ) } } @@ -530,7 +538,7 @@ class HomeActivity : ScreenLockActionBarActivity(), override fun onResume() { super.onResume() messageNotifier.setHomeScreenVisible(true) - if (textSecurePreferences.getLocalNumber() == null) { return; } // This can be the case after a secondary device is auto-cleared + if (loginStateRepository.getLocalNumber() == null) { return; } // This can be the case after a secondary device is auto-cleared IdentityKeyUtil.checkUpdate(this) if (textSecurePreferences.getHasViewedSeed()) { binding.seedReminderView.isVisible = false @@ -541,7 +549,7 @@ class HomeActivity : ScreenLockActionBarActivity(), override fun onPause() { super.onPause() - ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(false) + messageNotifier.setHomeScreenVisible(false) } override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { @@ -877,6 +885,21 @@ class HomeActivity : ScreenLockActionBarActivity(), private fun showStartConversation() { homeViewModel.onCommand(HomeViewModel.Commands.ShowStartConversationSheet) } + + private fun applyViewInsets() { + binding.root.applySafeInsetsPaddings( + applyBottom = false, + consumeInsets = false, + alsoApply = { insets -> + binding.globalSearchRecycler.updatePadding(bottom = insets.bottom) + } + ) + + binding.newConversationButton.applySafeInsetsMargins( + typeMask = WindowInsetsCompat.Type.navigationBars(), + additionalInsets = Insets.of(0,0,0, resources.getDimensionPixelSize(R.dimen.new_conversation_button_bottom_offset)) + ) + } } fun Context.startHomeActivity(isFromOnboarding: Boolean, isNewAccount: Boolean) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt index 27c8f4655d..186f9efc6e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt @@ -1,20 +1,35 @@ package org.thoughtcrime.securesms.home import androidx.compose.runtime.Composable -import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HandleUserProfileCommand -import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HidePinCTADialog -import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HideUserProfileModal -import org.thoughtcrime.securesms.home.startconversation.StartConversationDestination +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.squareup.phrase.Phrase +import kotlinx.coroutines.delay +import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.StringSubstitutionConstants +import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY +import org.thoughtcrime.securesms.home.HomeViewModel.Commands.* import org.thoughtcrime.securesms.home.startconversation.StartConversationSheet +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination +import org.thoughtcrime.securesms.ui.AnimatedSessionProCTA +import org.thoughtcrime.securesms.ui.CTAFeature +import org.thoughtcrime.securesms.ui.OpenURLAlertDialog import org.thoughtcrime.securesms.ui.PinProCTA -import org.thoughtcrime.securesms.ui.UINavigator +import org.thoughtcrime.securesms.ui.SimpleSessionProCTA import org.thoughtcrime.securesms.ui.UserProfileModal import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme @Composable fun HomeDialogs( dialogsState: HomeViewModel.DialogsState, - startConversationNavigator: UINavigator, sendCommand: (HomeViewModel.Commands) -> Unit ) { SessionMaterialTheme { @@ -22,6 +37,7 @@ fun HomeDialogs( if(dialogsState.pinCTA != null){ PinProCTA( overTheLimit = dialogsState.pinCTA.overTheLimit, + proSubscription = dialogsState.pinCTA.proSubscription, onDismissRequest = { sendCommand(HidePinCTADialog) } @@ -43,11 +59,138 @@ fun HomeDialogs( if(dialogsState.showStartConversationSheet != null){ StartConversationSheet( accountId = dialogsState.showStartConversationSheet.accountId, - navigator = startConversationNavigator, onDismissRequest = { sendCommand(HomeViewModel.Commands.HideStartConversationSheet) } ) } + + // we need a delay before displaying this. + // Setting the delay in the VM does not account for render and it seems to appear immediately + var showExpiring by remember { mutableStateOf(false) } + LaunchedEffect(dialogsState.proExpiringCTA) { + showExpiring = false + if (dialogsState.proExpiringCTA != null) { + delay(1500) + showExpiring = true + } + } + + if(showExpiring && dialogsState.proExpiringCTA != null){ + val context = LocalContext.current + AnimatedSessionProCTA( + heroImageBg = R.drawable.cta_hero_generic_bg, + heroImageAnimatedFg = R.drawable.cta_hero_generic_fg, + title = stringResource(R.string.proExpiringSoon), + badgeAtStart = true, + text = Phrase.from(context,R.string.proExpiringSoonDescription) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(TIME_KEY, dialogsState.proExpiringCTA.expiry) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + .toString(), + features = listOf( + CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), + CTAFeature.Icon(stringResource(R.string.proFeatureListPinnedConversations)), + CTAFeature.Icon(stringResource(R.string.proFeatureListAnimatedDisplayPicture)), + ), + positiveButtonText = stringResource(R.string.update), + negativeButtonText = stringResource(R.string.close), + onUpgrade = { + sendCommand(HomeViewModel.Commands.HideExpiringCTADialog) + sendCommand(HomeViewModel.Commands.GotoProSettings(ProSettingsDestination.ChoosePlan)) + }, + onCancel = { + sendCommand(HomeViewModel.Commands.HideExpiringCTADialog) + } + ) + } + + // we need a delay before displaying this. + // Setting the delay in the VM does not account for render and it seems to appear immediately + var showExpired by remember { mutableStateOf(false) } + LaunchedEffect(dialogsState.proExpiredCTA) { + showExpired = false + if (dialogsState.proExpiredCTA) { + delay(1500) + showExpired = true + } + } + + if (showExpired && dialogsState.proExpiredCTA) { + val context = LocalContext.current + AnimatedSessionProCTA( + heroImageBg = R.drawable.cta_hero_generic_bg, + heroImageAnimatedFg = R.drawable.cta_hero_generic_fg, + title = stringResource(R.string.proExpired), + badgeAtStart = true, + disabled = true, + text = Phrase.from(context,R.string.proExpiredDescription) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + .toString(), + features = listOf( + CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), + CTAFeature.Icon(stringResource(R.string.proFeatureListPinnedConversations)), + CTAFeature.Icon(stringResource(R.string.proFeatureListAnimatedDisplayPicture)), + ), + positiveButtonText = stringResource(R.string.renew), + negativeButtonText = stringResource(R.string.cancel), + onUpgrade = { + sendCommand(HideExpiredCTADialog) + sendCommand(GotoProSettings(ProSettingsDestination.ChoosePlan)) + }, + onCancel = { + sendCommand(HideExpiredCTADialog) + } + ) + } + + // we need a delay before displaying this. + // Setting the delay in the VM does not account for render and it seems to appear immediately + var showDonation by remember { mutableStateOf(false) } + LaunchedEffect(dialogsState.donationCTA) { + showDonation = false + if (dialogsState.donationCTA) { + delay(1500) + showDonation = true + } + } + + if (showDonation && dialogsState.donationCTA) { + val context = LocalContext.current + SimpleSessionProCTA( + heroImage = R.drawable.cta_hero_flower, + title = Phrase.from(context,R.string.donateSessionHelp) + .put(StringSubstitutionConstants.APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .format() + .toString(), + showProBadge = false, + text = Phrase.from(context,R.string.donateSessionDescription) + .put(StringSubstitutionConstants.APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .format() + .toString(), + positiveButtonText = stringResource(R.string.donate), + negativeButtonText = stringResource(R.string.maybeLater), + onUpgrade = { + sendCommand(HideDonationCTADialog) + sendCommand(ShowDonationConfirmation) + }, + onCancel = { + sendCommand(HideDonationCTADialog) + } + ) + } + + if(dialogsState.showUrlDialog != null){ + OpenURLAlertDialog( + url = dialogsState.showUrlDialog, + onLinkOpened = { sendCommand(OnLinkOpened(dialogsState.showUrlDialog)) }, + onLinkCopied = { sendCommand(OnLinkCopied(dialogsState.showUrlDialog)) }, + onDismissRequest = { sendCommand(HideUrlDialog) } + ) + } } } + diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index 770b83ad74..3333e6ad61 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch @@ -25,32 +26,41 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import network.loki.messenger.R -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN +import network.loki.messenger.libsession_util.PRIORITY_HIDDEN import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.utilities.Address import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.displayName -import org.session.libsession.utilities.recipients.shouldShowProBadge import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination +import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.DonationManager +import org.thoughtcrime.securesms.util.DonationManager.Companion.URL_DONATE import org.thoughtcrime.securesms.util.UserProfileModalCommands import org.thoughtcrime.securesms.util.UserProfileModalData import org.thoughtcrime.securesms.util.UserProfileUtils import org.thoughtcrime.securesms.webrtc.CallManager import org.thoughtcrime.securesms.webrtc.data.State +import java.time.Instant +import java.time.temporal.ChronoUnit import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( @param:ApplicationContext private val context: Context, private val prefs: TextSecurePreferences, + private val loginStateRepository: LoginStateRepository, private val typingStatusRepository: TypingStatusRepository, private val configFactory: ConfigFactory, callManager: CallManager, @@ -60,6 +70,8 @@ class HomeViewModel @Inject constructor( private val proStatusManager: ProStatusManager, private val upmFactory: UserProfileUtils.UserProfileUtilsFactory, private val recipientRepository: RecipientRepository, + private val dateUtils: DateUtils, + private val donationManager: DonationManager ) : ViewModel() { // SharedFlow that emits whenever the user asks us to reload the conversation private val manualReloadTrigger = MutableSharedFlow( @@ -82,6 +94,12 @@ class HomeViewModel @Inject constructor( private val _dialogsState = MutableStateFlow(DialogsState()) val dialogsState: StateFlow = _dialogsState + private val _uiEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1 + ) + val uiEvents: SharedFlow = _uiEvents + /** * A [StateFlow] that emits the list of threads and the typing status of each thread. * @@ -136,12 +154,63 @@ class HomeViewModel @Inject constructor( val shouldShowCurrentUserProBadge: StateFlow = recipientRepository .observeSelf() - .map { it.proStatus.shouldShowProBadge() } + .map { it.shouldShowProBadge } .stateIn(viewModelScope, SharingStarted.Eagerly, false) private var userProfileModalJob: Job? = null private var userProfileModalUtils: UserProfileUtils? = null + init { + // observe subscription status + viewModelScope.launch { + proStatusManager.proDataState.collect { subscription -> + // show a CTA (only once per install) when + // - subscription is expiring in less than 7 days + // - subscription expired less than 30 days ago + val now = Instant.now() + + var showExpiring: Boolean = false + var showExpired: Boolean = false + + if(subscription.type is ProStatus.Active.Expiring + && !prefs.hasSeenProExpiring() + ){ + val validUntil = subscription.type.validUntil + showExpiring = validUntil.isBefore(now.plus(7, ChronoUnit.DAYS)) + Log.d(DebugLogGroup.PRO_DATA.label, "Home: Pro active but not auto renewing (expiring). Valid until: $validUntil - Should show Expiring CTA? $showExpiring") + if (showExpiring) { + _dialogsState.update { state -> + state.copy( + proExpiringCTA = ProExpiringCTA( + dateUtils.getExpiryString(validUntil) + ) + ) + } + } + } + else if(subscription.type is ProStatus.Expired + && !prefs.hasSeenProExpired()) { + val validUntil = subscription.type.expiredAt + showExpired = now.isBefore(validUntil.plus(30, ChronoUnit.DAYS)) + + Log.d(DebugLogGroup.PRO_DATA.label, "Home: Pro expired. Expired at: $validUntil - Should show Expired CTA? $showExpired") + + // Check if now is within 30 days after expiry + if (showExpired) { + _dialogsState.update { state -> + state.copy(proExpiredCTA = true) + } + } + } + + // check if we should display the donation CTA - unless we have a pro CTA already + if(!showExpiring && !showExpired && donationManager.shouldShowDonationCTA()){ + showDonationCTA() + } + } + } + } + private fun observeTypingStatus(): Flow> = typingStatusRepository .typingThreads .asFlow() @@ -201,12 +270,16 @@ class HomeViewModel @Inject constructor( fun setPinned(address: Address, pinned: Boolean) { // check the pin limit before continuing val totalPins = storage.getTotalPinned() - val maxPins = proStatusManager.getPinnedConversationLimit(recipientRepository.getSelf().proStatus) + val maxPins = + proStatusManager.getPinnedConversationLimit(recipientRepository.getSelf().isPro) if (pinned && totalPins >= maxPins) { // the user has reached the pin limit, show the CTA _dialogsState.update { it.copy( - pinCTA = PinProCTA(overTheLimit = totalPins > maxPins) + pinCTA = PinProCTA( + overTheLimit = totalPins > maxPins, + proSubscription = proStatusManager.proDataState.value.type + ) ) } } else { @@ -233,7 +306,7 @@ class HomeViewModel @Inject constructor( is Commands.ShowStartConversationSheet -> { _dialogsState.update { it.copy(showStartConversationSheet = StartConversationSheetData( - accountId = prefs.getLocalNumber()!! + accountId = loginStateRepository.requireLocalNumber() ) ) } } @@ -241,9 +314,61 @@ class HomeViewModel @Inject constructor( is Commands.HideStartConversationSheet -> { _dialogsState.update { it.copy(showStartConversationSheet = null) } } + + is Commands.HideExpiringCTADialog -> { + prefs.setHasSeenProExpiring() + _dialogsState.update { it.copy(proExpiringCTA = null) } + } + + is Commands.HideExpiredCTADialog -> { + prefs.setHasSeenProExpired() + _dialogsState.update { it.copy(proExpiredCTA = false) } + } + + is Commands.GotoProSettings -> { + viewModelScope.launch { + _uiEvents.emit(UiEvent.OpenProSettings(command.destination)) + } + } + + is Commands.HideDonationCTADialog -> { + _dialogsState.update { it.copy(donationCTA = false) } + } + + is Commands.ShowDonationConfirmation -> { + showUrlDialog(URL_DONATE) + } + + is Commands.HideUrlDialog -> { + _dialogsState.update { it.copy(showUrlDialog = null) } + } + + is Commands.OnLinkOpened -> { + // if the link was for donation, mark it as seen + if(command.url == URL_DONATE) { + donationManager.onDonationSeen() + } + } + + is Commands.OnLinkCopied -> { + // if the link was for donation, mark it as seen + if(command.url == URL_DONATE) { + donationManager.onDonationCopied() + } + } } } + fun showDonationCTA(){ + _dialogsState.update { it.copy(donationCTA = true) } + donationManager.onDonationCTAViewed() + } + + fun showUrlDialog(url: String) { + _dialogsState.update { it.copy(showUrlDialog = url) } + } + + fun showUserProfileModal(thread: ThreadRecord) { // get the helper class for the selected user userProfileModalUtils = upmFactory.create( @@ -264,26 +389,50 @@ class HomeViewModel @Inject constructor( data class DialogsState( val pinCTA: PinProCTA? = null, val userProfileModal: UserProfileModalData? = null, - val showStartConversationSheet: StartConversationSheetData? = null + val showStartConversationSheet: StartConversationSheetData? = null, + val proExpiringCTA: ProExpiringCTA? = null, + val proExpiredCTA: Boolean = false, + val donationCTA: Boolean = false, + val showUrlDialog: String? = null, ) data class PinProCTA( - val overTheLimit: Boolean + val overTheLimit: Boolean, + val proSubscription: ProStatus + ) + + data class ProExpiringCTA( + val expiry: String ) data class StartConversationSheetData( val accountId: String ) + sealed interface UiEvent { + data class OpenProSettings(val start: ProSettingsDestination) : UiEvent + } + sealed interface Commands { data object HidePinCTADialog : Commands + data object HideExpiringCTADialog : Commands + data object HideExpiredCTADialog : Commands + data object ShowDonationConfirmation : Commands + data object HideDonationCTADialog : Commands data object HideUserProfileModal : Commands + data object HideUrlDialog : Commands + data class OnLinkOpened(val url: String) : Commands + data class OnLinkCopied(val url: String) : Commands data class HandleUserProfileCommand( val upmCommand: UserProfileModalCommands ) : Commands data object ShowStartConversationSheet : Commands data object HideStartConversationSheet : Commands + + data class GotoProSettings( + val destination: ProSettingsDestination + ): Commands } companion object { 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 4808702b2a..c95e9468bd 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 @@ -10,15 +10,10 @@ 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.messaging.MessagingModuleConfiguration import org.session.libsession.utilities.Address -import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientData import org.session.libsession.utilities.recipients.displayName -import org.session.libsession.utilities.recipients.shouldShowProBadge -import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.search.model.MessageResult import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.util.DateUtils @@ -197,14 +192,14 @@ class GlobalSearchAdapter( is Address.Group -> { val data = (recipient.data as? RecipientData.Group) - data?.partial?.members?.joinToString(", ") { it.name } + data?.members?.joinToString(", ") { it.name } } else -> { null } }, - showProBadge = recipient.proStatus.shouldShowProBadge() + showProBadge = recipient.shouldShowProBadge ) } data class Message(val messageResult: MessageResult, val unread: Int, val isSelf: Boolean, val showProBadge: Boolean) : Model diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt index f7a8c870df..e19cb6de74 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt @@ -16,12 +16,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import kotlinx.coroutines.launch @@ -38,6 +41,7 @@ import org.thoughtcrime.securesms.home.startconversation.newmessage.State import org.thoughtcrime.securesms.openUrl import org.thoughtcrime.securesms.ui.NavigationAction import org.thoughtcrime.securesms.ui.ObserveAsEvents +import org.thoughtcrime.securesms.ui.OpenURLAlertDialog import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.ui.components.BaseBottomSheet import org.thoughtcrime.securesms.ui.horizontalSlideComposable @@ -48,7 +52,6 @@ import org.thoughtcrime.securesms.ui.theme.PreviewTheme fun StartConversationSheet( modifier: Modifier = Modifier, accountId: String, - navigator: UINavigator, onDismissRequest: () -> Unit, ){ val sheetState = rememberModalBottomSheetState( @@ -71,7 +74,6 @@ fun StartConversationSheet( ) { StartConversationNavHost( accountId = accountId, - navigator = navigator, onClose = { scope.launch { sheetState.hide() @@ -107,124 +109,133 @@ sealed interface StartConversationDestination { @Composable fun StartConversationNavHost( accountId: String, - navigator: UINavigator, onClose: () -> Unit ){ - SharedTransitionLayout { - val navController = rememberNavController() - - ObserveAsEvents(flow = navigator.navigationActions) { action -> - when (action) { - is NavigationAction.Navigate -> navController.navigate( - action.destination - ) { - action.navOptions(this) - } - - NavigationAction.NavigateUp -> navController.navigateUp() + val navController = rememberNavController() + val navigator: UINavigator = + remember { UINavigator() } + + ObserveAsEvents(flow = navigator.navigationActions) { action -> + when (action) { + is NavigationAction.Navigate -> navController.navigate( + action.destination + ) { + action.navOptions(this) + } - is NavigationAction.NavigateToIntent -> { - navController.context.startActivity(action.intent) - } + NavigationAction.NavigateUp -> navController.navigateUp() - is NavigationAction.ReturnResult -> {} + is NavigationAction.NavigateToIntent -> { + navController.context.startActivity(action.intent) } + + else -> {} } + } - val scope = rememberCoroutineScope() - val activity = LocalActivity.current - val context = LocalContext.current + val scope = rememberCoroutineScope() + val activity = LocalActivity.current + val context = LocalContext.current + + NavHost(navController = navController, startDestination = StartConversationDestination.Home) { + // Home + horizontalSlideComposable { + StartConversationScreen ( + accountId = accountId, + onClose = onClose, + navigateTo = { + scope.launch { navigator.navigate(it) } + } + ) + } - NavHost(navController = navController, startDestination = StartConversationDestination.Home) { - // Home - horizontalSlideComposable { - StartConversationScreen ( - accountId = accountId, - onClose = onClose, - navigateTo = { - scope.launch { navigator.navigate(it) } - } - ) - } + // New Message + horizontalSlideComposable { + val viewModel = hiltViewModel() + val uiState by viewModel.state.collectAsState(State()) - // New Message - horizontalSlideComposable { - val viewModel = hiltViewModel() - val uiState by viewModel.state.collectAsState(State()) + val helpUrl = "https://getsession.org/account-ids" - LaunchedEffect(Unit) { - scope.launch { - viewModel.success.collect { - context.startActivity(ConversationActivityV2.createIntent( + LaunchedEffect(Unit) { + scope.launch { + viewModel.success.collect { + context.startActivity( + ConversationActivityV2.createIntent( context, address = it.address - )) + ) + ) - onClose() - } + onClose() } } - - NewMessage( - uiState, - viewModel.qrErrors, - viewModel, - onBack = { scope.launch { navigator.navigateUp() }}, - onClose = onClose, - onHelp = { activity?.openUrl("https://sessionapp.zendesk.com/hc/en-us/articles/4439132747033-How-do-Account-ID-usernames-work") } - ) } - // Create Group - horizontalSlideComposable { - CreateGroupScreen( - onNavigateToConversationScreen = { address -> - activity?.startActivity( - ConversationActivityV2.createIntent(activity, address) - ) - }, - onBack = { scope.launch { navigator.navigateUp() }}, - onClose = onClose, - fromLegacyGroupId = null, + NewMessage( + uiState, + viewModel.qrErrors, + viewModel, + onBack = { scope.launch { navigator.navigateUp() } }, + onClose = onClose, + onHelp = { viewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) } + ) + if (uiState.showUrlDialog) { + OpenURLAlertDialog( + url = helpUrl, + onDismissRequest = { viewModel.onCommand(NewMessageViewModel.Commands.DismissUrlDialog) } ) } + } + + // Create Group + horizontalSlideComposable { + CreateGroupScreen( + onNavigateToConversationScreen = { address -> + activity?.startActivity( + ConversationActivityV2.createIntent(activity, address) + ) + }, + onBack = { scope.launch { navigator.navigateUp() }}, + onClose = onClose, + fromLegacyGroupId = null, + ) + } - // Join Community - horizontalSlideComposable { - val viewModel = hiltViewModel() - val state by viewModel.state.collectAsState() - - LaunchedEffect(Unit){ - scope.launch { - viewModel.uiEvents.collect { - when(it){ - is JoinCommunityViewModel.UiEvent.NavigateToConversation -> { - onClose() - activity?.startActivity(ConversationActivityV2.createIntent(activity, it.address)) - } + // Join Community + horizontalSlideComposable { + val viewModel = hiltViewModel() + val state by viewModel.state.collectAsState() + + LaunchedEffect(Unit){ + scope.launch { + viewModel.uiEvents.collect { + when(it){ + is JoinCommunityViewModel.UiEvent.NavigateToConversation -> { + onClose() + activity?.startActivity(ConversationActivityV2.createIntent(activity, it.address)) } } } } - - JoinCommunityScreen( - state = state, - sendCommand = { viewModel.onCommand(it) }, - onBack = { scope.launch { navigator.navigateUp() }}, - onClose = onClose - ) } - // Invite Friend - horizontalSlideComposable { - InviteFriend( - accountId = accountId, - onBack = { scope.launch { navigator.navigateUp() }}, - onClose = onClose - ) - } + JoinCommunityScreen( + state = state, + sendCommand = { viewModel.onCommand(it) }, + onBack = { scope.launch { navigator.navigateUp() }}, + onClose = onClose + ) + } + // Invite Friend + horizontalSlideComposable { + InviteFriend( + accountId = accountId, + onBack = { scope.launch { navigator.navigateUp() }}, + onClose = onClose + ) } + } } @@ -235,7 +246,6 @@ fun PreviewStartConversationSheet(){ StartConversationSheet( accountId = "", onDismissRequest = {}, - navigator = UINavigator() ) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt index cf8f078478..f45bad3bfc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import network.loki.messenger.R import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.groups.ContactItem @@ -65,8 +65,8 @@ fun CreateGroupScreen( viewModel.events.collect { event -> when (event) { is CreateGroupEvent.NavigateToConversation -> { - onClose() onNavigateToConversationScreen(event.address) + onClose() } is CreateGroupEvent.Error -> { @@ -190,7 +190,9 @@ fun CreateGroup( .padding(horizontal = LocalDimensions.current.spacing) .qaTag(R.string.AccessibilityId_groupCreate) ) { - LoadingArcOr(loading = showLoading) { + LoadingArcOr( + loading = showLoading + ) { Text(stringResource(R.string.create)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupViewModel.kt index 9a5ebeb878..2136ec045d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupViewModel.kt @@ -46,6 +46,7 @@ class CreateGroupViewModel @AssistedInject constructor( excludingAccountIDs = emptySet(), contactFiltering = SelectContactsViewModel.Factory.defaultFiltering, recipientRepository = recipientRepository, + context = appContext ) { // Child view model to handle contact selection logic diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt index cf0cff108b..8981f6c99c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt @@ -124,7 +124,7 @@ private fun EnterAccountId( .fillMaxWidth(), style = LocalType.current.small, color = LocalColors.current.textSecondary, - iconRes = R.drawable.ic_circle_help, + iconRes = R.drawable.ic_square_arrow_up_right, onClick = onHelp ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt index 1402f7ac56..d76cdcf457 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt @@ -4,6 +4,7 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.squareup.phrase.Phrase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow @@ -19,9 +20,11 @@ import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.upsertContact import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.PublicKeyValidation +import org.thoughtcrime.securesms.preferences.SettingsViewModel import org.thoughtcrime.securesms.ui.GetString import java.net.IDN import javax.inject.Inject @@ -69,10 +72,20 @@ class NewMessageViewModel @Inject constructor( } } - if (PublicKeyValidation.isValid(idOrONS, isPrefixRequired = false)) { - onUnvalidatedPublicKey(publicKey = idOrONS) + if (PublicKeyValidation.hasValidLength(idOrONS)) { + if (PublicKeyValidation.isValid(idOrONS, isPrefixRequired = false)) { + onUnvalidatedPublicKey(idOrONS) + } else { + _state.update { + it.copy( + isTextErrorColor = true, + error = GetString(R.string.accountIdErrorInvalid), + loading = false + ) + } + } } else { - resolveONS(ons = idOrONS) + resolveONS(idOrONS) } } @@ -122,7 +135,6 @@ class NewMessageViewModel @Inject constructor( if (address is Address.Standard) { viewModelScope.launch { _success.emit(Success(address)) } } - } private fun onUnvalidatedPublicKey(publicKey: String) { @@ -134,8 +146,31 @@ class NewMessageViewModel @Inject constructor( } private fun Exception.toMessage() = when (this) { - is SnodeAPI.Error.Generic -> application.getString(R.string.onsErrorNotRecognized) - else -> application.getString(R.string.onsErrorUnableToSearch) + is SnodeAPI.Error.Generic -> application.getString(R.string.errorUnregisteredOns) + else -> Phrase.from(application, R.string.errorNoLookupOns) + .put(APP_NAME_KEY, application.getString(R.string.app_name)) + .format().toString() + } + + fun onCommand(commands: Commands) { + when (commands) { + is Commands.ShowUrlDialog -> { + _state.update { it.copy(showUrlDialog = true) } + } + + is Commands.DismissUrlDialog -> { + _state.update { + it.copy( + showUrlDialog = false + ) + } + } + } + } + + sealed interface Commands { + data object ShowUrlDialog : Commands + data object DismissUrlDialog : Commands } } @@ -143,9 +178,13 @@ data class State( val newMessageIdOrOns: String = "", val isTextErrorColor: Boolean = false, val error: GetString? = null, - val loading: Boolean = false + val loading: Boolean = false, + val showUrlDialog : Boolean = false ) { val isNextButtonEnabled: Boolean get() = newMessageIdOrOns.isNotBlank() } + + + data class Success(val address: Address.Standard) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewActivity.kt index 0acbdfa303..65d4db87de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewActivity.kt @@ -5,7 +5,7 @@ import android.content.Intent import androidx.activity.viewModels import androidx.compose.runtime.Composable import androidx.core.content.IntentCompat -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import dagger.hilt.android.AndroidEntryPoint import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.FullComposeScreenLockActivity diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt index 38bf670080..6416061ba4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import network.loki.messenger.R import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.BasicSessionAlertDialog import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator @@ -266,7 +267,7 @@ private fun DeleteConfirmationDialog( private fun ActionProgressDialog( text: String ) { - BasicAlertDialog( + BasicSessionAlertDialog( onDismissRequest = {}, ) { Row( diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt index 807fff6de5..6d8c645b3e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt @@ -57,7 +57,8 @@ class MediaOverviewViewModel @AssistedInject constructor( private val threadDatabase: ThreadDatabase, private val mediaDatabase: MediaDatabase, private val dateUtils: DateUtils, - private val recipientRepository: RecipientRepository, + recipientRepository: RecipientRepository, + private val messageSender: MessageSender, ) : AndroidViewModel(application) { private val timeBuckets by lazy { FixedTimeBuckets() } @@ -293,7 +294,7 @@ class MediaOverviewViewModel @AssistedInject constructor( val timestamp = SnodeAPI.nowWithOffset val kind = DataExtractionNotification.Kind.MediaSaved(timestamp) val message = DataExtractionNotification(kind) - MessageSender.send(message, address) + messageSender.send(message, address) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt index 184a45916d..617fd51087 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt @@ -14,7 +14,6 @@ import network.loki.messenger.R import network.loki.messenger.databinding.ViewMessageRequestBinding import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.displayName -import org.session.libsession.utilities.recipients.shouldShowProBadge import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.model.ThreadRecord @@ -83,7 +82,7 @@ class MessageRequestView : LinearLayout { ProBadgeText( text = senderDisplayName, textStyle = LocalType.current.h8.bold().copy(color = LocalColors.current.text), - showBadge = thread.recipient.proStatus.shouldShowProBadge() + showBadge = thread.recipient.shouldShowProBadge && !thread.recipient.isLocalNumber, ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java index 2d22a13532..62173b3390 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java @@ -25,17 +25,24 @@ import androidx.core.app.NotificationManagerCompat; +import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.database.MarkedMessageInfo; +import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import java.util.LinkedList; import java.util.List; +import javax.inject.Inject; + +import dagger.hilt.android.AndroidEntryPoint; + /** * Marks an Android Auto as read after the driver have listened to it */ +@AndroidEntryPoint public class AndroidAutoHeardReceiver extends BroadcastReceiver { public static final String TAG = AndroidAutoHeardReceiver.class.getSimpleName(); @@ -43,6 +50,10 @@ public class AndroidAutoHeardReceiver extends BroadcastReceiver { public static final String THREAD_IDS_EXTRA = "car_heard_thread_ids"; public static final String NOTIFICATION_ID_EXTRA = "car_notification_id"; + @Inject MarkReadProcessor markReadProcessor; + @Inject MessageNotifier messageNotifier; + @Inject ThreadDatabase threadDb; + @SuppressLint("StaticFieldLeak") @Override public void onReceive(final Context context, Intent intent) @@ -63,13 +74,13 @@ protected Void doInBackground(Void... params) { for (long threadId : threadIds) { Log.i(TAG, "Marking meassage as read: " + threadId); - List messageIds = DatabaseComponent.get(context).threadDatabase().setRead(threadId, true); + List messageIds = threadDb.setRead(threadId, true); messageIdsCollection.addAll(messageIds); } - ApplicationContext.getInstance(context).getMessageNotifier().updateNotification(context); - MarkReadReceiver.process(context, messageIdsCollection); + messageNotifier.updateNotification(context); + markReadProcessor.process(messageIdsCollection); return null; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java deleted file mode 100644 index 26561eee2a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright (C) 2011 Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.thoughtcrime.securesms.notifications; - -import android.annotation.SuppressLint; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.os.AsyncTask; -import android.os.Bundle; - -import androidx.core.app.RemoteInput; - -import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage; -import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; -import org.session.libsession.messaging.messages.visible.VisibleMessage; -import org.session.libsession.messaging.sending_receiving.MessageSender; -import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; -import org.session.libsession.snode.SnodeAPI; -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.AddressKt; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.database.MarkedMessageInfo; -import org.thoughtcrime.securesms.database.MmsDatabase; -import org.thoughtcrime.securesms.database.RecipientRepository; -import org.thoughtcrime.securesms.database.SmsDatabase; -import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.mms.MmsException; - -import java.util.Collections; -import java.util.List; - -import javax.inject.Inject; - -import dagger.hilt.android.AndroidEntryPoint; -import network.loki.messenger.libsession_util.util.ExpiryMode; - -/** - * Get the response text from the Android Auto and sends an message as a reply - */ -@AndroidEntryPoint -public class AndroidAutoReplyReceiver extends BroadcastReceiver { - - public static final String TAG = AndroidAutoReplyReceiver.class.getSimpleName(); - public static final String REPLY_ACTION = "network.loki.securesms.notifications.ANDROID_AUTO_REPLY"; - public static final String ADDRESS_EXTRA = "car_address"; - public static final String VOICE_REPLY_KEY = "car_voice_reply_key"; - public static final String THREAD_ID_EXTRA = "car_reply_thread_id"; - - @Inject - ThreadDatabase threadDatabase; - - @Inject - RecipientRepository recipientRepository; - - @Inject - MmsDatabase mmsDatabase; - - @Inject - SmsDatabase smsDatabase; - - @Inject - MessageNotifier messageNotifier; - - @SuppressLint("StaticFieldLeak") - @Override - public void onReceive(final Context context, Intent intent) - { - if (!REPLY_ACTION.equals(intent.getAction())) return; - - Bundle remoteInput = RemoteInput.getResultsFromIntent(intent); - - if (remoteInput == null) return; - - final Address address = intent.getParcelableExtra(ADDRESS_EXTRA); - final long threadId = intent.getLongExtra(THREAD_ID_EXTRA, -1); - final CharSequence responseText = getMessageText(intent); - - if (responseText != null) { - new AsyncTask() { - @Override - protected Void doInBackground(Void... params) { - - long replyThreadId; - - if (threadId == -1) { - replyThreadId = threadDatabase.getOrCreateThreadIdFor(address); - } else { - replyThreadId = threadId; - } - - VisibleMessage message = new VisibleMessage(); - message.setText(responseText.toString()); - message.setSentTimestamp(SnodeAPI.getNowWithOffset()); - MessageSender.send(message, address); - ExpiryMode expiryMode = recipientRepository.getRecipientSync(address).getExpiryMode(); - long expiresInMillis = expiryMode.getExpiryMillis(); - long expireStartedAt = expiryMode instanceof ExpiryMode.AfterSend ? message.getSentTimestamp() : 0L; - - if (AddressKt.isGroupOrCommunity(address)) { - Log.w("AndroidAutoReplyReceiver", "GroupRecipient, Sending media message"); - OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, address, Collections.emptyList(), null, null, expiresInMillis, 0); - try { - mmsDatabase.insertMessageOutbox(reply, replyThreadId, false, true); - } catch (MmsException e) { - Log.w(TAG, e); - } - } else { - Log.w("AndroidAutoReplyReceiver", "Sending regular message "); - OutgoingTextMessage reply = OutgoingTextMessage.from(message, address, expiresInMillis, expireStartedAt); - smsDatabase.insertMessageOutbox(replyThreadId, reply, false, SnodeAPI.getNowWithOffset(), true); - } - - List messageIds = threadDatabase.setRead(replyThreadId, true); - - messageNotifier.updateNotification(context); - MarkReadReceiver.process(context, messageIds); - - return null; - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - } - - private CharSequence getMessageText(Intent intent) { - Bundle remoteInput = RemoteInput.getResultsFromIntent(intent); - if (remoteInput != null) { - return remoteInput.getCharSequence(VOICE_REPLY_KEY); - } - return null; - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.kt new file mode 100644 index 0000000000..9972ca2685 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.kt @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.notifications + +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.AsyncTask +import androidx.core.app.RemoteInput +import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.libsession_util.util.ExpiryMode.AfterSend +import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage +import org.session.libsession.messaging.messages.signal.OutgoingTextMessage +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.messaging.sending_receiving.attachments.Attachment +import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier +import org.session.libsession.snode.SnodeAPI.nowWithOffset +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.isGroupOrCommunity +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.database.SmsDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.mms.MmsException +import javax.inject.Inject + +/** + * Get the response text from the Android Auto and sends an message as a reply + */ +@AndroidEntryPoint +class AndroidAutoReplyReceiver : BroadcastReceiver() { + @Inject + lateinit var threadDatabase: ThreadDatabase + + @Inject + lateinit var recipientRepository: RecipientRepository + + @Inject + lateinit var mmsDatabase: MmsDatabase + + @Inject + lateinit var smsDatabase: SmsDatabase + + @Inject + lateinit var messageNotifier: MessageNotifier + + @Inject + lateinit var markReadProcessor: MarkReadProcessor + + @Inject + lateinit var messageSender: MessageSender + + @SuppressLint("StaticFieldLeak") + override fun onReceive(context: Context, intent: Intent) { + if (REPLY_ACTION != intent.getAction()) return + + val remoteInput = RemoteInput.getResultsFromIntent(intent) + + if (remoteInput == null) return + + val address = intent.getParcelableExtra(ADDRESS_EXTRA) + val threadId = intent.getLongExtra(THREAD_ID_EXTRA, -1) + val responseText = getMessageText(intent) + + if (responseText != null) { + object : AsyncTask() { + override fun doInBackground(vararg params: Void?): Void? { + val replyThreadId: Long + + if (threadId == -1L) { + replyThreadId = threadDatabase.getOrCreateThreadIdFor(address) + } else { + replyThreadId = threadId + } + + val message = VisibleMessage() + message.text = responseText.toString() + message.sentTimestamp = nowWithOffset + messageSender.send(message, address!!) + val expiryMode = recipientRepository.getRecipientSync(address).expiryMode + val expiresInMillis = expiryMode.expiryMillis + val expireStartedAt: Long = + (if (expiryMode is AfterSend) message.sentTimestamp!! else 0L) + + if (address.isGroupOrCommunity) { + Log.w("AndroidAutoReplyReceiver", "GroupRecipient, Sending media message") + val reply = OutgoingMediaMessage( + message = message, + recipient = address, + attachments = listOf(), + outgoingQuote = null, + linkPreview = null, + expiresInMillis = expiresInMillis, + expireStartedAt = 0 + ) + try { + mmsDatabase.insertMessageOutbox( + message = reply, + threadId = replyThreadId, + forceSms = false, + runThreadUpdate = true + ) + } catch (e: MmsException) { + Log.w(TAG, e) + } + } else { + Log.w("AndroidAutoReplyReceiver", "Sending regular message ") + val reply = OutgoingTextMessage( + message = message, + recipient = address, + expiresInMillis = expiresInMillis, + expireStartedAtMillis = expireStartedAt + ) + smsDatabase.insertMessageOutbox( + replyThreadId, + reply, + false, + nowWithOffset, + true + ) + } + + val messageIds = threadDatabase.setRead(replyThreadId, true) + + messageNotifier.updateNotification(context) + markReadProcessor.process(messageIds) + + return null + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + } + } + + private fun getMessageText(intent: Intent): CharSequence? { + val remoteInput = RemoteInput.getResultsFromIntent(intent) + if (remoteInput != null) { + return remoteInput.getCharSequence(VOICE_REPLY_KEY) + } + return null + } + + companion object { + val TAG: String = AndroidAutoReplyReceiver::class.java.getSimpleName() + const val REPLY_ACTION: String = "network.loki.securesms.notifications.ANDROID_AUTO_REPLY" + const val ADDRESS_EXTRA: String = "car_address" + const val VOICE_REPLY_KEY: String = "car_voice_reply_key" + const val THREAD_ID_EXTRA: String = "car_reply_thread_id" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollManager.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollManager.kt index 71124c6556..3506df7782 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollManager.kt @@ -12,8 +12,8 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.util.AppVisibilityManager import javax.inject.Inject @@ -28,16 +28,16 @@ import javax.inject.Singleton class BackgroundPollManager @Inject constructor( application: Application, appVisibilityManager: AppVisibilityManager, - textSecurePreferences: TextSecurePreferences, + loginStateRepository: LoginStateRepository, ) : OnAppStartupComponent { init { @Suppress("OPT_IN_USAGE") GlobalScope.launch { combine( - textSecurePreferences.watchLocalNumber(), + loginStateRepository.loggedInState, // Debounce to avoid rapid toggling on visible app starts appVisibilityManager.isAppVisible.debounce(1_000L) - ) { localNumber, appVisible -> localNumber != null && !appVisible } + ) { loggedInState, appVisible -> loggedInState != null && !appVisible } .distinctUntilChanged() .collectLatest { shouldSchedule -> if (shouldSchedule) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt index d5c9846245..c1d398a451 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt @@ -31,8 +31,6 @@ import org.session.libsession.messaging.sending_receiving.notifications.MessageN import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.ServiceUtil import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber import org.session.libsession.utilities.TextSecurePreferences.Companion.getNotificationPrivacy import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotificationsEnabled import org.session.libsession.utilities.TextSecurePreferences.Companion.removeHasHiddenMessageRequests @@ -41,8 +39,8 @@ import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions -import org.thoughtcrime.securesms.crypto.KeyPairUtilities.getUserED25519KeyPair import org.thoughtcrime.securesms.database.MmsSmsColumns.NOTIFIED import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.RecipientRepository @@ -75,8 +73,8 @@ class DefaultMessageNotifier @Inject constructor( private val threadDatabase: ThreadDatabase, private val recipientRepository: RecipientRepository, private val mmsSmsDatabase: MmsSmsDatabase, - private val textSecurePreferences: TextSecurePreferences, private val imageLoader: Provider, + private val loginStateRepository: LoginStateRepository, ) : MessageNotifier { override fun setVisibleThread(threadId: Long) { visibleThread = threadId @@ -204,7 +202,7 @@ class DefaultMessageNotifier @Inject constructor( incomingCursor = mmsSmsDatabase.getUnreadIncomingForNotifications(MAX_ROWS) reactionsCursor = mmsSmsDatabase.getOutgoingWithUnseenReactionsForNotifications(MAX_ROWS) - val localNumber = textSecurePreferences.getLocalNumber() + val localNumber = loginStateRepository.peekLoginState()?.accountId?.hexString val hasIncoming = incomingCursor != null && incomingCursor.count > 0 val hasReactions = reactionsCursor != null && reactionsCursor.count > 0 val nothingToDo = !hasIncoming && !hasReactions @@ -601,7 +599,7 @@ class DefaultMessageNotifier @Inject constructor( // Check notification settings if (threadRecipients?.notifyType == NotifyType.NONE) continue - val userPublicKey = getLocalNumber(context) + val userPublicKey = loginStateRepository.requireLocalNumber() // Check mentions-only setting if (threadRecipients?.notifyType == NotifyType.MENTIONS) { @@ -803,7 +801,7 @@ class DefaultMessageNotifier @Inject constructor( private fun generateBlindedId(threadId: Long, context: Context): String? { val threadRecipient = recipientRepository.getRecipientSync(threadDatabase.getRecipientForThreadId(threadId) ?: return null) val serverPubKey = (threadRecipient.data as? RecipientData.Community)?.serverPubKey - val edKeyPair = getUserED25519KeyPair(context) + val edKeyPair = loginStateRepository.peekLoginState()?.accountEd25519KeyPair if (serverPubKey != null && edKeyPair != null) { val blindedKeyPair = BlindKeyAPI.blind15KeyPairOrNull( ed25519SecretKey = edKeyPair.secretKey.data, diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt new file mode 100644 index 0000000000..04cfd04597 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt @@ -0,0 +1,134 @@ +package org.thoughtcrime.securesms.notifications + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.session.libsession.database.StorageProtocol +import org.session.libsession.database.userAuth +import org.session.libsession.messaging.messages.control.ReadReceipt +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeClock +import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled +import org.session.libsession.utilities.associateByNotNull +import org.session.libsession.utilities.isGroupOrCommunity +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.RecipientData +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType +import org.thoughtcrime.securesms.database.LokiMessageDatabase +import org.thoughtcrime.securesms.database.MarkedMessageInfo +import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.database.SmsDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate +import javax.inject.Inject + +class MarkReadProcessor @Inject constructor( + @param:ApplicationContext private val context: Context, + private val recipientRepository: RecipientRepository, + private val messageSender: MessageSender, + private val mmsSmsDatabase: MmsSmsDatabase, + private val mmsDatabase: MmsDatabase, + private val smsDatabase: SmsDatabase, + private val threadDb: ThreadDatabase, + private val storage: StorageProtocol, + private val snodeClock: SnodeClock, + private val lokiMessageDatabase: LokiMessageDatabase, +) { + fun process( + markedReadMessages: List + ) { + if (markedReadMessages.isEmpty()) return + + sendReadReceipts( + markedReadMessages = markedReadMessages + ) + + + // start disappear after read messages except TimerUpdates in groups. + markedReadMessages + .asSequence() + .filter { it.expiryType == ExpiryType.AFTER_READ } + .filter { mmsSmsDatabase.getMessageById(it.expirationInfo.id)?.run { + (messageContent is DisappearingMessageUpdate) + && threadDb.getRecipientForThreadId(threadId)?.isGroupOrCommunity == true } == false + } + .forEach { + val db = if (it.expirationInfo.id.mms) { + mmsDatabase + } else { + smsDatabase + } + + db.markExpireStarted(it.expirationInfo.id.id, snodeClock.currentTimeMills()) + } + + hashToDisappearAfterReadMessage(context, markedReadMessages)?.let { hashToMessages -> + GlobalScope.launch { + try { + shortenExpiryOfDisappearingAfterRead(hashToMessages) + } catch (e: Exception) { + Log.e(TAG, "Failed to fetch updated expiries and schedule deletion", e) + } + } + } + } + + private fun hashToDisappearAfterReadMessage( + context: Context, + markedReadMessages: List + ): Map? { + return markedReadMessages + .filter { it.expiryType == ExpiryType.AFTER_READ } + .associateByNotNull { it.expirationInfo.run { lokiMessageDatabase.getMessageServerHash(id) } } + .takeIf { it.isNotEmpty() } + } + + private fun shortenExpiryOfDisappearingAfterRead( + hashToMessage: Map + ) { + hashToMessage.entries + .groupBy( + keySelector = { it.value.expirationInfo.expiresIn }, + valueTransform = { it.key } + ).forEach { (expiresIn, hashes) -> + SnodeAPI.alterTtl( + messageHashes = hashes, + newExpiry = snodeClock.currentTimeMills() + expiresIn, + auth = checkNotNull(storage.userAuth) { "No authorized user" }, + shorten = true + ) + } + } + + private val Recipient.shouldSendReadReceipt: Boolean + get() = when (data) { + is RecipientData.Contact -> approved && !blocked + is RecipientData.Generic -> !isGroupOrCommunityRecipient && !blocked + else -> false + } + + private fun sendReadReceipts( + markedReadMessages: List + ) { + if (!isReadReceiptsEnabled(context)) return + + markedReadMessages.map { it.syncMessageId } + .filter { recipientRepository.getRecipientSync(it.address).shouldSendReadReceipt } + .groupBy { it.address } + .forEach { (address, messages) -> + messages.map { it.timetamp } + .let(::ReadReceipt) + .apply { sentTimestamp = snodeClock.currentTimeMills() } + .let { messageSender.send(it, address) } + } + } + + companion object { + private const val TAG = "MarkReadProcessor" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt index 1b8cb58d23..6b1cd80d4d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt @@ -8,24 +8,8 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import org.session.libsession.database.StorageProtocol -import org.session.libsession.database.userAuth -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.shared -import org.session.libsession.messaging.messages.control.ReadReceipt -import org.session.libsession.messaging.sending_receiving.MessageSender.send -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeAPI.nowWithOffset import org.session.libsession.snode.SnodeClock -import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled -import org.session.libsession.utilities.associateByNotNull -import org.session.libsession.utilities.isGroupOrCommunity -import org.session.libsession.utilities.recipients.RecipientData -import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType -import org.thoughtcrime.securesms.database.MarkedMessageInfo -import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate -import org.thoughtcrime.securesms.dependencies.DatabaseComponent import javax.inject.Inject @AndroidEntryPoint @@ -36,6 +20,7 @@ class MarkReadReceiver : BroadcastReceiver() { @Inject lateinit var clock: SnodeClock + override fun onReceive(context: Context, intent: Intent) { if (CLEAR_ACTION != intent.action) return val threadIds = intent.getLongArrayExtra(THREAD_IDS_EXTRA) ?: return @@ -59,101 +44,5 @@ class MarkReadReceiver : BroadcastReceiver() { const val THREAD_IDS_EXTRA = "thread_ids" const val NOTIFICATION_ID_EXTRA = "notification_id" - @JvmStatic - fun process( - context: Context, - markedReadMessages: List - ) { - if (markedReadMessages.isEmpty()) return - - sendReadReceipts(context, markedReadMessages) - - val mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase() - - val threadDb = DatabaseComponent.get(context).threadDatabase() - - // start disappear after read messages except TimerUpdates in groups. - markedReadMessages - .asSequence() - .filter { it.expiryType == ExpiryType.AFTER_READ } - .filter { mmsSmsDatabase.getMessageById(it.expirationInfo.id)?.run { - (messageContent is DisappearingMessageUpdate) - && threadDb.getRecipientForThreadId(threadId)?.isGroupOrCommunity == true } == false - } - .forEach { - val db = if (it.expirationInfo.id.mms) { - DatabaseComponent.get(context).mmsDatabase() - } else { - DatabaseComponent.get(context).smsDatabase() - } - - db.markExpireStarted(it.expirationInfo.id.id, nowWithOffset) - } - - hashToDisappearAfterReadMessage(context, markedReadMessages)?.let { hashToMessages -> - GlobalScope.launch { - try { - shortenExpiryOfDisappearingAfterRead(hashToMessages) - } catch (e: Exception) { - Log.e(TAG, "Failed to fetch updated expiries and schedule deletion", e) - } - } - } - } - - private fun hashToDisappearAfterReadMessage( - context: Context, - markedReadMessages: List - ): Map? { - val loki = DatabaseComponent.get(context).lokiMessageDatabase() - - return markedReadMessages - .filter { it.expiryType == ExpiryType.AFTER_READ } - .associateByNotNull { it.expirationInfo.run { loki.getMessageServerHash(id) } } - .takeIf { it.isNotEmpty() } - } - - private fun shortenExpiryOfDisappearingAfterRead( - hashToMessage: Map - ) { - hashToMessage.entries - .groupBy( - keySelector = { it.value.expirationInfo.expiresIn }, - valueTransform = { it.key } - ).forEach { (expiresIn, hashes) -> - SnodeAPI.alterTtl( - messageHashes = hashes, - newExpiry = nowWithOffset + expiresIn, - auth = checkNotNull(shared.storage.userAuth) { "No authorized user" }, - shorten = true - ) - } - } - - private val Recipient.shouldSendReadReceipt: Boolean - get() = when (data) { - is RecipientData.Contact -> approved && !blocked - is RecipientData.Generic -> !isGroupOrCommunityRecipient && !blocked - else -> false - } - - private fun sendReadReceipts( - context: Context, - markedReadMessages: List - ) { - if (!isReadReceiptsEnabled(context)) return - - val recipientRepository = MessagingModuleConfiguration.shared.recipientRepository - - markedReadMessages.map { it.syncMessageId } - .filter { recipientRepository.getRecipientSync(it.address)?.shouldSendReadReceipt == true } - .groupBy { it.address } - .forEach { (address, messages) -> - messages.map { it.timetamp } - .let(::ReadReceipt) - .apply { sentTimestamp = nowWithOffset } - .let { send(it, address) } - } - } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationItem.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationItem.java index 0454cb8599..c0042fb271 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationItem.java @@ -72,10 +72,12 @@ public long getThreadId() { } public PendingIntent getPendingIntent(Context context) { - - Recipient notifyRecipients = threadRecipient != null ? threadRecipient : conversationRecipient; + Recipient notifyRecipients = getRecipient(); final Intent intent = ConversationActivityV2.Companion.createIntent(context, (Address.Conversable) notifyRecipients.getAddress()); - intent.setData((Uri.parse("custom://"+System.currentTimeMillis()))); + intent.setData(Uri.parse("custom://" + System.currentTimeMillis()) + .buildUpon() + .appendQueryParameter("address", notifyRecipients.getAddress().toString()) + .build()); int intentFlags = PendingIntent.FLAG_UPDATE_CURRENT; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -83,8 +85,8 @@ public PendingIntent getPendingIntent(Context context) { } return TaskStackBuilder.create(context) - .addNextIntentWithParentStack(intent) - .getPendingIntent(0, intentFlags); + .addNextIntentWithParentStack(intent) + .getPendingIntent(0, intentFlags); } public long getId() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt index 8c34519359..e256c17feb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt @@ -10,31 +10,33 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat.getString import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import network.loki.messenger.R import network.loki.messenger.libsession_util.Namespace import network.loki.messenger.libsession_util.SessionEncrypt import okio.ByteString.Companion.decodeHex -import org.session.libsession.messaging.jobs.BatchMessageReceiveJob -import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.jobs.MessageReceiveParameters -import org.session.libsession.messaging.messages.Destination +import org.session.libsession.messaging.messages.Message.Companion.senderOrSync +import org.session.libsession.messaging.sending_receiving.MessageParser +import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationMetadata -import org.session.libsession.messaging.utilities.MessageWrapper +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigMessage import org.session.libsession.utilities.bencode.Bencode import org.session.libsession.utilities.bencode.BencodeList import org.session.libsession.utilities.bencode.BencodeString import org.session.libsession.utilities.getGroup -import org.session.libsignal.protos.SignalServiceProtos.Envelope import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.crypto.IdentityKeyUtil +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.groups.GroupRevokedMessageHandler import org.thoughtcrime.securesms.home.HomeActivity import java.security.SecureRandom @@ -47,7 +49,11 @@ class PushReceiver @Inject constructor( private val configFactory: ConfigFactory, private val groupRevokedMessageHandler: GroupRevokedMessageHandler, private val json: Json, - private val batchJobFactory: BatchMessageReceiveJob.Factory, + private val messageParser: MessageParser, + private val receivedMessageProcessor: ReceivedMessageProcessor, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, + @param:ManagerScope private val scope: CoroutineScope, + private val loginStateRepository: LoginStateRepository, ) { /** @@ -70,7 +76,7 @@ class PushReceiver @Inject constructor( private fun addMessageReceiveJob(pushData: PushData?) { try { val namespace = pushData?.metadata?.namespace - val params = when { + when { namespace == Namespace.GROUP_MESSAGES() || namespace == Namespace.REVOKED_GROUP_MESSAGES() || namespace == Namespace.GROUP_INFO() || @@ -91,48 +97,63 @@ class PushReceiver @Inject constructor( return } - if (namespace == Namespace.GROUP_MESSAGES()) { - val envelope = checkNotNull(tryDecryptGroupEnvelope(groupId, pushData.data)) { - "Unable to decrypt closed group message" + when (namespace) { + Namespace.GROUP_MESSAGES() -> { + if (!receivedMessageHashDatabase.checkOrUpdateDuplicateState( + swarmPublicKey = groupId.hexString, + namespace = namespace, + hash = pushData.metadata.msg_hash + )) { + receivedMessageProcessor.startProcessing("GroupPushReceive($groupId)") { ctx -> + val (msg, proto) = messageParser.parseGroupMessage( + data = pushData.data, + serverHash = pushData.metadata.msg_hash, + groupId = groupId, + currentUserId = ctx.currentUserId, + currentUserEd25519PrivKey = ctx.currentUserEd25519KeyPair.secretKey.data + ) + + receivedMessageProcessor.processSwarmMessage( + threadAddress = Address.Group(groupId), + message = msg, + proto = proto, + context = ctx, + ) + } + } } - MessageReceiveParameters( - data = envelope.toByteArray(), - serverHash = pushData.metadata.msg_hash, - closedGroup = Destination.ClosedGroup(groupId.hexString) - ) - } else if (namespace == Namespace.REVOKED_GROUP_MESSAGES()) { - GlobalScope.launch { - groupRevokedMessageHandler.handleRevokeMessage(groupId, listOf(pushData.data)) + Namespace.REVOKED_GROUP_MESSAGES() -> { + scope.launch { + groupRevokedMessageHandler.handleRevokeMessage(groupId, listOf(pushData.data)) + } } - null - } else { - val hash = requireNotNull(pushData.metadata.msg_hash) { - "Received a closed group config push notification without a message hash" - } + else -> { + val hash = requireNotNull(pushData.metadata.msg_hash) { + "Received a closed group config push notification without a message hash" + } + + // If we receive group config messages from notification, try to merge + // them directly + val configMessage = listOf( + ConfigMessage( + hash = hash, + data = pushData.data, + timestamp = pushData.metadata.timestampSeconds + ) + ) - // If we receive group config messages from notification, try to merge - // them directly - val configMessage = listOf( - ConfigMessage( - hash = hash, - data = pushData.data, - timestamp = pushData.metadata.timestampSeconds + configFactory.mergeGroupConfigMessages( + groupId = groupId, + keys = configMessage.takeIf { namespace == Namespace.GROUP_KEYS() } + .orEmpty(), + members = configMessage.takeIf { namespace == Namespace.GROUP_MEMBERS() } + .orEmpty(), + info = configMessage.takeIf { namespace == Namespace.GROUP_INFO() } + .orEmpty(), ) - ) - - configFactory.mergeGroupConfigMessages( - groupId = groupId, - keys = configMessage.takeIf { namespace == Namespace.GROUP_KEYS() } - .orEmpty(), - members = configMessage.takeIf { namespace == Namespace.GROUP_MEMBERS() } - .orEmpty(), - info = configMessage.takeIf { namespace == Namespace.GROUP_INFO() } - .orEmpty(), - ) - - null + } } } @@ -146,11 +167,29 @@ class PushReceiver @Inject constructor( return } - val envelopeAsData = MessageWrapper.unwrap(pushData.data).toByteArray() - MessageReceiveParameters( - data = envelopeAsData, - serverHash = pushData.metadata?.msg_hash + val isDuplicated = pushData.metadata?.msg_hash != null && receivedMessageHashDatabase.checkOrUpdateDuplicateState( + swarmPublicKey = pushData.metadata.account, + namespace = Namespace.DEFAULT(), + hash = pushData.metadata.msg_hash ) + + if (!isDuplicated) { + receivedMessageProcessor.startProcessing("PushReceiver") { ctx -> + val (message, proto) = messageParser.parse1o1Message( + data = pushData.data, + serverHash = pushData.metadata?.msg_hash, + currentUserId = ctx.currentUserId, + currentUserEd25519PrivKey = ctx.currentUserEd25519KeyPair.secretKey.data, + ) + + receivedMessageProcessor.processSwarmMessage( + threadAddress = message.senderOrSync.toAddress() as Address.Conversable, + message = message, + proto = proto, + context = ctx, + ) + } + } } else -> { @@ -159,33 +198,12 @@ class PushReceiver @Inject constructor( } } - if (params != null) { - JobQueue.shared.add(batchJobFactory.create( - messages = listOf(params), - fromCommunity = null - )) - } } catch (e: Exception) { Log.d(TAG, "Failed to unwrap data for message due to error.", e) } } - private fun tryDecryptGroupEnvelope(groupId: AccountId, data: ByteArray): Envelope? { - val (envelopBytes, sender) = checkNotNull(configFactory.withGroupConfigs(groupId) { - it.groupKeys.decrypt( - data - ) - }) { - "Failed to decrypt group message" - } - - Log.d(TAG, "Successfully decrypted group message from $sender") - return Envelope.parseFrom(envelopBytes) - .toBuilder() - .setSource(sender) - .build() - } private fun sendGenericNotification() { // no need to do anything if notification permissions are not granted @@ -230,7 +248,10 @@ class PushReceiver @Inject constructor( private fun decrypt(encPayload: ByteArray): PushData { Log.d(TAG, "decrypt() called") - val encKey = getOrCreateNotificationKey() + val encKey = checkNotNull(loginStateRepository.loggedInState?.value?.notificationKey?.data) { + "No notification key available to decrypt push notification" + } + val decrypted = SessionEncrypt.decryptPushNotification( message = encPayload, secretKey = encKey @@ -253,19 +274,7 @@ class PushReceiver @Inject constructor( } } - fun getOrCreateNotificationKey(): ByteArray { - val keyHex = IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY) - if (keyHex != null) { - return keyHex.decodeHex().toByteArray() - } - - // generate the key and store it - val key = ByteArray(32).also { SecureRandom().nextBytes(it) } - IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.toHexString()) - return key - } - - data class PushData( + class PushData( val data: ByteArray?, val metadata: PushNotificationMetadata? ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt index 2e904cec1d..fedb28109f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt @@ -9,10 +9,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore @@ -23,6 +21,7 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.userConfigsChanged import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.PushRegistrationDatabase import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ManagerScope @@ -46,8 +45,8 @@ class PushRegistrationHandler @Inject constructor( @param:ManagerScope private val scope: CoroutineScope, @param:PushNotificationModule.PushProcessingSemaphore private val semaphore: Semaphore, - private val storage: StorageProtocol, private val pushRegistrationDatabase: PushRegistrationDatabase, + private val loginStateRepository: LoginStateRepository, ) : OnAppStartupComponent { private var job: Job? = null @@ -60,30 +59,23 @@ class PushRegistrationHandler @Inject constructor( job = scope.launch { val firstRun = AtomicBoolean(true) - @Suppress("OPT_IN_USAGE") - preferences.watchLocalNumber() - .filterNotNull() - .distinctUntilChanged() - .flatMapLatest { localNumber -> - if (hasCoreIdentity()) { - combine( - configFactory.userConfigsChanged( - onlyConfigTypes = EnumSet.of(UserConfigType.USER_GROUPS), - debounceMills = 500 - ) - .castAwayType() - .onStart { emit(Unit) }, - preferences.pushEnabled, - tokenFetcher.token.filterNotNull().filter { !it.isBlank() } - ) { _, enabled, token -> - if (enabled) { - desiredSubscriptions(localNumber, token) - } else { - emptyList() - } + loginStateRepository + .flowWithLoggedInState { + combine( + configFactory.userConfigsChanged( + onlyConfigTypes = EnumSet.of(UserConfigType.USER_GROUPS), + debounceMills = 500 + ) + .castAwayType() + .onStart { emit(Unit) }, + preferences.pushEnabled, + tokenFetcher.token.filterNotNull().filter { !it.isBlank() } + ) { _, enabled, token -> + if (enabled) { + desiredSubscriptions(loginStateRepository.requireLocalNumber(), token) + } else { + emptyList() } - } else { - emptyFlow() } } .distinctUntilChanged() @@ -123,10 +115,6 @@ class PushRegistrationHandler @Inject constructor( } } - private fun hasCoreIdentity(): Boolean { - return preferences.getLocalNumber() != null && storage.getUserED25519KeyPair() != null - } - companion object { private const val TAG = "PushRegistrationHandler" } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt index 97302d8337..da6be333f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt @@ -24,11 +24,11 @@ import org.session.libsession.database.userAuth import org.session.libsession.messaging.sending_receiving.notifications.Response import org.session.libsession.snode.SwarmAuth import org.session.libsession.utilities.ConfigFactoryProtocol -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.PushRegistrationDatabase import org.thoughtcrime.securesms.util.getRootCause import java.time.Duration @@ -45,7 +45,7 @@ class PushRegistrationWorker @AssistedInject constructor( private val storage: StorageProtocol, private val pushRegistrationDatabase: PushRegistrationDatabase, private val configFactory: ConfigFactoryProtocol, - private val prefs: TextSecurePreferences, + private val loginStateRepository: LoginStateRepository, @param:PushNotificationModule.PushProcessingSemaphore private val semaphore: Semaphore, ) : CoroutineWorker(context, params) { @@ -221,7 +221,7 @@ class PushRegistrationWorker @AssistedInject constructor( } } - accountId.hexString == prefs.getLocalNumber() -> { + accountId == loginStateRepository.requireLocalAccountId() -> { requireNotNull(storage.userAuth) { "User auth is required for local number push registration" } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt index 2df1d2d781..a83c0b882d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt @@ -24,6 +24,7 @@ import org.session.libsession.snode.Version import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Device import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.auth.LoginStateRepository import javax.inject.Inject import javax.inject.Singleton @@ -32,13 +33,10 @@ typealias SignedUnsubscriptionRequest = JsonObject @Singleton class PushRegistryV2 @Inject constructor( - private val pushReceiver: PushReceiver, private val device: Device, - private val clock: SnodeClock + private val clock: SnodeClock, + private val loginStateRepository: LoginStateRepository, ) { - private val pnKey by lazy { - pushReceiver.getOrCreateNotificationKey() - } suspend fun register( requests: Collection @@ -69,7 +67,9 @@ class PushRegistryV2 @Inject constructor( service = device.service, sig_ts = timestamp, service_info = mapOf("token" to token), - enc_key = pnKey.toHexString(), + enc_key = requireNotNull(loginStateRepository.peekLoginState()) { + "User must be logged in to register for push notifications" + }.notificationKey.data.toHexString(), ).let(Json::encodeToJsonElement).jsonObject + signed } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java deleted file mode 100644 index 216eadaae0..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright (C) 2016 Open Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.thoughtcrime.securesms.notifications; - -import android.annotation.SuppressLint; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.os.AsyncTask; -import android.os.Bundle; - -import androidx.core.app.RemoteInput; - -import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage; -import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; -import org.session.libsession.messaging.messages.visible.VisibleMessage; -import org.session.libsession.messaging.sending_receiving.MessageSender; -import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; -import org.session.libsession.snode.SnodeClock; -import org.session.libsession.utilities.Address; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.database.MarkedMessageInfo; -import org.thoughtcrime.securesms.database.MmsDatabase; -import org.thoughtcrime.securesms.database.RecipientRepository; -import org.thoughtcrime.securesms.database.SmsDatabase; -import org.thoughtcrime.securesms.database.Storage; -import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.database.model.MessageId; -import org.thoughtcrime.securesms.mms.MmsException; - -import java.util.Collections; -import java.util.List; - -import javax.inject.Inject; - -import dagger.hilt.android.AndroidEntryPoint; -import network.loki.messenger.libsession_util.util.ExpiryMode; - -/** - * Get the response text from the Wearable Device and sends an message as a reply - */ -@AndroidEntryPoint -public class RemoteReplyReceiver extends BroadcastReceiver { - - public static final String TAG = RemoteReplyReceiver.class.getSimpleName(); - public static final String REPLY_ACTION = "network.loki.securesms.notifications.WEAR_REPLY"; - public static final String ADDRESS_EXTRA = "address"; - public static final String REPLY_METHOD = "reply_method"; - - @Inject - ThreadDatabase threadDatabase; - @Inject - MmsDatabase mmsDatabase; - @Inject - SmsDatabase smsDatabase; - @Inject - Storage storage; - @Inject - MessageNotifier messageNotifier; - @Inject - SnodeClock clock; - @Inject - RecipientRepository recipientRepository; - - @SuppressLint("StaticFieldLeak") - @Override - public void onReceive(final Context context, Intent intent) { - if (!REPLY_ACTION.equals(intent.getAction())) return; - - Bundle remoteInput = RemoteInput.getResultsFromIntent(intent); - - if (remoteInput == null) return; - - final Address address = intent.getParcelableExtra(ADDRESS_EXTRA); - final ReplyMethod replyMethod = (ReplyMethod) intent.getSerializableExtra(REPLY_METHOD); - final CharSequence responseText = remoteInput.getCharSequence(DefaultMessageNotifier.EXTRA_REMOTE_REPLY); - - if (address == null) throw new AssertionError("No address specified"); - if (replyMethod == null) throw new AssertionError("No reply method specified"); - - if (responseText != null) { - new AsyncTask() { - @Override - protected Void doInBackground(Void... params) { - long threadId = threadDatabase.getOrCreateThreadIdFor(address); - VisibleMessage message = new VisibleMessage(); - message.setSentTimestamp(clock.currentTimeMills()); - message.setText(responseText.toString()); - ExpiryMode expiryMode = recipientRepository.getRecipientSync(address).getExpiryMode(); - - long expiresInMillis = expiryMode.getExpiryMillis(); - long expireStartedAt = expiryMode instanceof ExpiryMode.AfterSend ? message.getSentTimestamp() : 0L; - switch (replyMethod) { - case GroupMessage: { - OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, address, Collections.emptyList(), null, null, expiresInMillis, 0); - try { - message.setId(new MessageId(mmsDatabase.insertMessageOutbox(reply, threadId, false, true), true)); - MessageSender.send(message, address); - } catch (MmsException e) { - Log.w(TAG, e); - } - break; - } - case SecureMessage: { - OutgoingTextMessage reply = OutgoingTextMessage.from(message, address, expiresInMillis, expireStartedAt); - message.setId(new MessageId(smsDatabase.insertMessageOutbox(threadId, reply, false, System.currentTimeMillis(), true), false)); - MessageSender.send(message, address); - break; - } - default: - throw new AssertionError("Unknown Reply method"); - } - - List messageIds = threadDatabase.setRead(threadId, true); - - messageNotifier.updateNotification(context); - MarkReadReceiver.process(context, messageIds); - - return null; - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.kt new file mode 100644 index 0000000000..f9e18340ea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.kt @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2016 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.notifications + +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.AsyncTask +import androidx.core.app.RemoteInput +import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.libsession_util.util.ExpiryMode.AfterSend +import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage +import org.session.libsession.messaging.messages.signal.OutgoingTextMessage +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.messaging.sending_receiving.attachments.Attachment +import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier +import org.session.libsession.snode.SnodeClock +import org.session.libsession.utilities.Address +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.database.SmsDatabase +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.mms.MmsException +import javax.inject.Inject + +/** + * Get the response text from the Wearable Device and sends an message as a reply + */ +@AndroidEntryPoint +class RemoteReplyReceiver : BroadcastReceiver() { + @Inject + lateinit var threadDatabase: ThreadDatabase + + @Inject + lateinit var mmsDatabase: MmsDatabase + + @Inject + lateinit var smsDatabase: SmsDatabase + + @Inject + lateinit var storage: Storage + + @Inject + lateinit var messageNotifier: MessageNotifier + + @Inject + lateinit var clock: SnodeClock + + @Inject + lateinit var recipientRepository: RecipientRepository + + @Inject + lateinit var markReadProcessor: MarkReadProcessor + + @Inject + lateinit var messageSender: MessageSender + + @SuppressLint("StaticFieldLeak") + override fun onReceive(context: Context, intent: Intent) { + if (REPLY_ACTION != intent.getAction()) return + + val remoteInput = RemoteInput.getResultsFromIntent(intent) + + if (remoteInput == null) return + + val address = intent.getParcelableExtra(ADDRESS_EXTRA) + val replyMethod = intent.getSerializableExtra(REPLY_METHOD) as ReplyMethod? + val responseText = remoteInput.getCharSequence(DefaultMessageNotifier.EXTRA_REMOTE_REPLY) + + if (address == null) throw AssertionError("No address specified") + if (replyMethod == null) throw AssertionError("No reply method specified") + + if (responseText != null) { + object : AsyncTask() { + override fun doInBackground(vararg params: Void?): Void? { + val threadId = threadDatabase.getOrCreateThreadIdFor(address) + val message = VisibleMessage() + message.sentTimestamp = clock.currentTimeMills() + message.text = responseText.toString() + val expiryMode = recipientRepository.getRecipientSync(address).expiryMode + + val expiresInMillis = expiryMode.expiryMillis + val expireStartedAt: Long = + (if (expiryMode is AfterSend) message.sentTimestamp else 0L)!! + when (replyMethod) { + ReplyMethod.GroupMessage -> { + val reply = OutgoingMediaMessage( + message = message, + recipient = address, + attachments = listOf(), + outgoingQuote = null, + linkPreview = null, + expiresInMillis = expiresInMillis, + expireStartedAt = 0 + ) + try { + message.id = MessageId( + mmsDatabase.insertMessageOutbox( + message = reply, + threadId = threadId, + forceSms = false, + runThreadUpdate = true + ), true + ) + messageSender.send(message, address) + } catch (e: MmsException) { + Log.w(TAG, e) + } + } + + ReplyMethod.SecureMessage -> { + val reply = OutgoingTextMessage( + message = message, + recipient = address, + expiresInMillis = expiresInMillis, + expireStartedAtMillis = expireStartedAt + ) + message.id = MessageId( + smsDatabase.insertMessageOutbox( + threadId, + reply, + false, + System.currentTimeMillis(), + true + ), false + ) + messageSender.send(message, address) + } + } + + val messageIds = threadDatabase.setRead(threadId, true) + + messageNotifier.updateNotification(context) + markReadProcessor.process(messageIds) + + return null + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + } + } + + companion object { + val TAG: String = RemoteReplyReceiver::class.java.getSimpleName() + const val REPLY_ACTION: String = "network.loki.securesms.notifications.WEAR_REPLY" + const val ADDRESS_EXTRA: String = "address" + const val REPLY_METHOD: String = "reply_method" + } +} 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 index d66338ca3f..5362632d4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt @@ -39,12 +39,10 @@ import kotlinx.coroutines.delay import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY -import org.thoughtcrime.securesms.ui.AlertDialog -import org.thoughtcrime.securesms.ui.DialogButtonData -import org.thoughtcrime.securesms.ui.GetString -import org.thoughtcrime.securesms.ui.components.BorderlessHtmlButton +import org.thoughtcrime.securesms.ui.TCPolicyDialog import org.thoughtcrime.securesms.ui.components.AccentFillButton import org.thoughtcrime.securesms.ui.components.AccentOutlineButton +import org.thoughtcrime.securesms.ui.components.BorderlessHtmlButton import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -60,7 +58,7 @@ private fun PreviewLandingScreen( @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors ) { PreviewTheme(colors) { - LandingScreen({}, {}, {}, {}) + LandingScreen({}, {}) } } @@ -68,8 +66,6 @@ private fun PreviewLandingScreen( internal fun LandingScreen( createAccount: () -> Unit, loadAccount: () -> Unit, - openTerms: () -> Unit, - openPrivacyPolicy: () -> Unit, ) { var count by remember { mutableStateOf(0) } val listState = rememberLazyListState() @@ -77,21 +73,10 @@ internal fun LandingScreen( var isUrlDialogVisible by remember { mutableStateOf(false) } if (isUrlDialogVisible) { - AlertDialog( - onDismissRequest = { isUrlDialogVisible = false }, - title = stringResource(R.string.urlOpen), - text = stringResource(R.string.urlOpenBrowser), - showCloseButton = true, // display the 'x' button - buttons = listOf( - DialogButtonData( - text = GetString(R.string.onboardingTos), - onClick = openTerms - ), - DialogButtonData( - text = GetString(R.string.onboardingPrivacy), - onClick = openPrivacyPolicy - ) - ) + TCPolicyDialog( + tcsUrl = "https://getsession.org/terms-of-service", + privacyUrl = "https://getsession.org/privacy-policy", + onDismissRequest = { isUrlDialogVisible = false }, ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/LandingActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/LandingActivity.kt index 3be3eafcc2..dcee8a0da5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/LandingActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/LandingActivity.kt @@ -33,9 +33,7 @@ class LandingActivity: BaseActionBarActivity() { setComposeContent { LandingScreen( createAccount = { startPickDisplayNameActivity() }, - loadAccount = { start() }, - openTerms = { open("https://getsession.org/terms-of-service") }, - openPrivacyPolicy = { open("https://getsession.org/privacy-policy") } + loadAccount = { start() } ) } 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 index afa5bb7870..ede53b87af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt @@ -43,7 +43,6 @@ class LoadAccountActivity : BaseActionBarActivity() { supportActionBar?.setTitle(R.string.loadAccount) prefs.setConfigurationMessageSynced(false) - prefs.setRestorationTime(System.currentTimeMillis()) lifecycleScope.launch { viewModel.events.collect { 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 index c13b50f106..cc373aaf1c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingViewModel.kt @@ -26,6 +26,7 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.userConfigsChanged import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.util.castAwayType import java.util.EnumSet import javax.inject.Inject @@ -50,6 +51,7 @@ private val REFRESH_TIME = 50.milliseconds internal class LoadingViewModel @Inject constructor( val prefs: TextSecurePreferences, val configFactory: ConfigFactoryProtocol, + private val loginStateRepository: LoginStateRepository, ): ViewModel() { private val state = MutableStateFlow(State.LOADING) @@ -73,18 +75,19 @@ internal class LoadingViewModel @Inject constructor( viewModelScope.launch { try { - configFactory.userConfigsChanged(onlyConfigTypes = EnumSet.of(UserConfigType.USER_PROFILE)) - .filter { it.fromMerge } - .castAwayType() - .onStart { emit(Unit) } - .filter { - prefs.getLocalNumber() != null && - configFactory.withUserConfigs { configs -> - !configs.userProfile.getName().isNullOrEmpty() + loginStateRepository.flowWithLoggedInState { + configFactory.userConfigsChanged(onlyConfigTypes = EnumSet.of(UserConfigType.USER_PROFILE)) + .filter { it.fromMerge } + .castAwayType() + .onStart { emit(Unit) } + .filter { + configFactory.withUserConfigs { configs -> + !configs.userProfile.getName().isNullOrEmpty() + } } - } - .timeout(TIMEOUT_TIME) + }.timeout(TIMEOUT_TIME) .first() + onSuccess() } catch (e: Exception) { Log.d("LoadingViewModel", "Failed to load user configs", e) 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 index 5602ccb9d7..6e6dd857de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt @@ -1,24 +1,24 @@ package org.thoughtcrime.securesms.onboarding.manager import android.app.Application -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN +import network.loki.messenger.libsession_util.PRIORITY_HIDDEN import org.session.libsession.snode.SnodeModule import org.session.libsession.utilities.ConfigFactoryProtocol 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.auth.LoggedInState +import org.thoughtcrime.securesms.auth.LoginStateRepository +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.util.VersionDataFetcher import javax.inject.Inject import javax.inject.Singleton @Singleton class CreateAccountManager @Inject constructor( - private val application: Application, - private val prefs: TextSecurePreferences, private val versionDataFetcher: VersionDataFetcher, - private val configFactory: ConfigFactoryProtocol + private val configFactory: ConfigFactoryProtocol, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, + private val loginStateRepository: LoginStateRepository, ) { private val database: LokiAPIDatabaseProtocol get() = SnodeModule.shared.storage @@ -27,19 +27,15 @@ class CreateAccountManager @Inject constructor( // 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() + receivedMessageHashDatabase.removeAll() - val keyPairGenerationResult = KeyPairUtilities.generate() - val seed = keyPairGenerationResult.seed - val ed25519KeyPair = keyPairGenerationResult.ed25519KeyPair - val x25519KeyPair = keyPairGenerationResult.x25519KeyPair + loginStateRepository.update { oldState -> + require(oldState == null) { + "Attempting to create a new account when one already exists!" + } - KeyPairUtilities.store(application, seed, ed25519KeyPair, x25519KeyPair) - val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey - val registrationID = KeyHelper.generateRegistrationId(false) - prefs.setLocalRegistrationId(registrationID) - prefs.setLocalNumber(userHexEncodedPublicKey) - prefs.setRestorationTime(0) + LoggedInState.generate(seed = null) + } configFactory.withMutableUserConfigs { it.userProfile.setName(displayName) 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 index 489742a9a2..d1a033182b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt @@ -8,19 +8,20 @@ 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 org.thoughtcrime.securesms.auth.LoggedInState +import org.thoughtcrime.securesms.auth.LoginStateRepository +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.util.VersionDataFetcher import javax.inject.Inject import javax.inject.Singleton @Singleton class LoadAccountManager @Inject constructor( - @dagger.hilt.android.qualifiers.ApplicationContext private val context: Context, + @param:dagger.hilt.android.qualifiers.ApplicationContext private val context: Context, private val prefs: TextSecurePreferences, - private val versionDataFetcher: VersionDataFetcher + private val versionDataFetcher: VersionDataFetcher, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, + private val loginStateRepository: LoginStateRepository, ) { private val database: LokiAPIDatabaseProtocol get() = SnodeModule.shared.storage @@ -37,21 +38,19 @@ class LoadAccountManager @Inject constructor( // 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) - val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey - val registrationID = org.session.libsignal.utilities.KeyHelper.generateRegistrationId(false) - prefs.apply { - setLocalRegistrationId(registrationID) - setLocalNumber(userHexEncodedPublicKey) - setRestorationTime(System.currentTimeMillis()) - setHasViewedSeed(true) + receivedMessageHashDatabase.removeAll() + + loginStateRepository.update { + require(it == null) { + "Attempting to restore an account when one already exists!" + } + + LoggedInState.generate(seed) } + // Mark that the user has viewed their seed to prevent being prompted again + prefs.setHasViewedSeed(true) + versionDataFetcher.startTimedVersionCheck() } } 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 index 3c5bb0eb75..8b3bcd7060 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.onboarding.messagenotifications +import android.R.attr.checked +import android.R.attr.onClick import androidx.annotation.StringRes import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box @@ -7,8 +9,11 @@ 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.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.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -54,52 +59,65 @@ internal fun MessageNotificationsScreen( return } - if (state.showingBackWarningDialogText != null) { - OnboardingBackPressAlertDialog(dismissDialog, + if (state.showingBackWarningDialogText != null) { + OnboardingBackPressAlertDialog( + dismissDialog, textId = state.showingBackWarningDialogText, quit = quit ) } - Column { - Spacer(Modifier.weight(1f)) - Column(modifier = Modifier.padding(horizontal = LocalDimensions.current.mediumSpacing)) { - Text(stringResource(R.string.notificationsMessage), style = LocalType.current.h4) - Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) - Text( - Phrase.from(stringResource(R.string.onboardingMessageNotificationExplanation)) - .put(APP_NAME_KEY, stringResource(R.string.app_name)) - .format().toString(), - style = LocalType.current.base - ) - Spacer(Modifier.height(LocalDimensions.current.spacing)) - } - - NotificationRadioButton( - R.string.notificationsFastMode, - if(BuildConfig.FLAVOR == "huawei") R.string.notificationsFastModeDescriptionHuawei - else R.string.notificationsFastModeDescription, - modifier = Modifier.qaTag(R.string.AccessibilityId_notificationsFastMode), - tag = R.string.recommended, - checked = state.pushEnabled, - onClick = { setEnabled(true) } - ) + val scroll = rememberScrollState() - // spacing between buttons is provided by ripple/downstate of NotificationRadioButton + Column(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(scroll), + contentAlignment = Alignment.Center + ) { + Column(modifier = Modifier.padding(horizontal = LocalDimensions.current.mediumSpacing)) { + Text(stringResource(R.string.notificationsMessage), style = LocalType.current.h4) + Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) + Text( + Phrase.from(stringResource(R.string.onboardingMessageNotificationExplanation)) + .put(APP_NAME_KEY, stringResource(R.string.app_name)) + .format().toString(), + style = LocalType.current.base + ) + Spacer(Modifier.height(LocalDimensions.current.spacing)) + + NotificationRadioButton( + R.string.notificationsFastMode, + if (BuildConfig.FLAVOR == "huawei") R.string.notificationsFastModeDescriptionHuawei + else R.string.notificationsFastModeDescription, + modifier = Modifier + .qaTag(R.string.AccessibilityId_notificationsFastMode) + .fillMaxWidth(), + tag = R.string.recommended, + checked = state.pushEnabled, + onClick = { setEnabled(true) } + ) - val explanationTxt = Phrase.from(stringResource(R.string.notificationsSlowModeDescription)) - .put(APP_NAME_KEY, stringResource(R.string.app_name)) - .format().toString() + // spacing between buttons is provided by ripple/downstate of NotificationRadioButton - NotificationRadioButton( - stringResource(R.string.notificationsSlowMode), - explanationTxt, - modifier = Modifier.qaTag(R.string.AccessibilityId_notificationsSlowMode), - checked = state.pushDisabled, - onClick = { setEnabled(false) } - ) + val explanationTxt = + Phrase.from(stringResource(R.string.notificationsSlowModeDescription)) + .put(APP_NAME_KEY, stringResource(R.string.app_name)) + .format().toString() - Spacer(Modifier.weight(1f)) + NotificationRadioButton( + stringResource(R.string.notificationsSlowMode), + explanationTxt, + modifier = Modifier + .qaTag(R.string.AccessibilityId_notificationsSlowMode) + .fillMaxWidth(), + checked = state.pushDisabled, + onClick = { setEnabled(false) } + ) + } + } ContinueAccentOutlineButton(Modifier.align(Alignment.CenterHorizontally), onContinue) } @@ -116,12 +134,12 @@ private fun NotificationRadioButton( ) { // Pass-through from this string ID version to the version that takes strings NotificationRadioButton( - titleTxt = stringResource(titleId), + titleTxt = stringResource(titleId), explanationTxt = stringResource(explanationId), - modifier = modifier, - tag = tag, - checked = checked, - onClick = onClick + modifier = modifier, + tag = tag, + checked = checked, + onClick = onClick ) } @@ -138,7 +156,9 @@ private fun NotificationRadioButton( onClick = onClick, modifier = modifier, selected = checked, - contentPadding = PaddingValues(horizontal = LocalDimensions.current.mediumSpacing, vertical = 7.dp) + contentPadding = PaddingValues( + vertical = 7.dp + ) ) { Box( modifier = Modifier @@ -150,7 +170,11 @@ private fun NotificationRadioButton( ), ) { Column( - modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing, vertical = LocalDimensions.current.xsSpacing)) { + modifier = Modifier.padding( + horizontal = LocalDimensions.current.smallSpacing, + vertical = LocalDimensions.current.xsSpacing + ) + ) { Text( titleTxt, style = LocalType.current.h8 diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt index 0141baab2d..2151c0a364 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt @@ -1,7 +1,7 @@ package org.thoughtcrime.securesms.preferences import androidx.compose.runtime.Composable -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import dagger.hilt.android.AndroidEntryPoint import org.thoughtcrime.securesms.FullComposeScreenLockActivity diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt index 4dc04a007c..cf2666cd9d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt @@ -34,6 +34,7 @@ class BlockedContactsViewModel @Inject constructor( avatarUtils = avatarUtils, proStatusManager = proStatusManager, recipientRepository = recipientRepository, + context = context ) { private val _unblockDialog = MutableStateFlow(false) val unblockDialog: StateFlow = _unblockDialog 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 3585a51fa6..f9b44b4cb1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt @@ -14,15 +14,16 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import dagger.hilt.android.AndroidEntryPoint 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 org.session.libsession.utilities.Address -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.PublicKeyValidation import org.thoughtcrime.securesms.ScreenLockActionBarActivity +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.ui.components.QRScannerScreen @@ -34,11 +35,16 @@ import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.util.applySafeInsetsPaddings +import javax.inject.Inject private val TITLES = listOf(R.string.view, R.string.scan) +@AndroidEntryPoint class QRCodeActivity : ScreenLockActionBarActivity() { + @Inject + lateinit var loginStateRepository: LoginStateRepository + override val applyDefaultWindowInsets: Boolean get() = false @@ -57,7 +63,7 @@ class QRCodeActivity : ScreenLockActionBarActivity() { setComposeContent { Tabs( - TextSecurePreferences.getLocalNumber(this)!!, + loginStateRepository.requireLocalNumber(), errors.asSharedFlow(), onScan = ::onScan ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt index ebedc45577..06a27e8455 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt @@ -70,7 +70,6 @@ import org.session.libsession.utilities.NonTranslatableStringConstants.NETWORK_N import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY -import org.session.libsession.utilities.recipients.ProStatus import org.thoughtcrime.securesms.debugmenu.DebugActivity import org.thoughtcrime.securesms.home.PathActivity import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity @@ -79,15 +78,15 @@ import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogStat import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.* import org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsActivity -import org.thoughtcrime.securesms.pro.SubscriptionState -import org.thoughtcrime.securesms.pro.SubscriptionType -import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.thoughtcrime.securesms.pro.ProDataState +import org.thoughtcrime.securesms.pro.ProStatus +import org.thoughtcrime.securesms.pro.previewAutoRenewingApple import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity import org.thoughtcrime.securesms.tokenpage.TokenPageActivity import org.thoughtcrime.securesms.ui.AccountIdHeader import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.AnimatedProfilePicProCTA -import org.thoughtcrime.securesms.ui.AnimatedSessionProActivatedCTA +import org.thoughtcrime.securesms.ui.CTAAnimatedImages import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.Divider @@ -99,6 +98,7 @@ import org.thoughtcrime.securesms.ui.PathDot import org.thoughtcrime.securesms.ui.ProBadge import org.thoughtcrime.securesms.ui.ProBadgeText import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.ui.SessionProCTA import org.thoughtcrime.securesms.ui.components.AcccentOutlineCopyButton import org.thoughtcrime.securesms.ui.components.AccentOutlineButton import org.thoughtcrime.securesms.ui.components.AnnotatedTextWithIcon @@ -130,8 +130,6 @@ import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUIElement import org.thoughtcrime.securesms.util.State import org.thoughtcrime.securesms.util.push -import java.time.Duration -import java.time.Instant @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -265,11 +263,11 @@ fun Settings( }, text = uiState.username, iconSize = 53.sp to 24.sp, - content = if(uiState.isPro){{ + content = if(uiState.proDataState.type !is ProStatus.NeverSubscribed){{ // if we are pro or expired ProBadge( modifier = Modifier.padding(start = 4.dp) .qaTag(stringResource(R.string.qa_pro_badge_icon)), - colors = if(uiState.subscriptionState is SubscriptionType.Active) + colors = if(uiState.proDataState.type is ProStatus.Active) proBadgeColorStandard() else proBadgeColorDisabled() ) @@ -301,7 +299,7 @@ fun Settings( recoveryHidden = uiState.recoveryHidden, hasPaths = uiState.hasPath, postPro = uiState.isPostPro, - subscriptionState = uiState.subscriptionState, + proDataState = uiState.proDataState, sendCommand = sendCommand ) @@ -383,7 +381,7 @@ fun Settings( if(uiState.showAvatarDialog) { AvatarDialog( state = uiState.avatarDialogState, - isPro = uiState.isPro, + isPro = uiState.proDataState.type is ProStatus.Active, isPostPro = uiState.isPostPro, sendCommand = sendCommand, startAvatarSelection = startAvatarSelection @@ -393,7 +391,7 @@ fun Settings( // Animated avatar CTA if(uiState.showAnimatedProCTA){ AnimatedProCTA( - isPro = uiState.isPro, + proSubscription = uiState.proDataState.type, sendCommand = sendCommand ) } @@ -402,6 +400,8 @@ fun Settings( if(uiState.showUrlDialog != null){ OpenURLAlertDialog( url = uiState.showUrlDialog, + onLinkOpened = { sendCommand(OnLinkOpened(uiState.showUrlDialog)) }, + onLinkCopied = { sendCommand(OnLinkCopied(uiState.showUrlDialog)) }, onDismissRequest = { sendCommand(HideUrlDialog) } ) } @@ -480,7 +480,7 @@ fun Buttons( recoveryHidden: Boolean, hasPaths: Boolean, postPro: Boolean, - subscriptionState: SubscriptionState, + proDataState: ProDataState, sendCommand: (SettingsViewModel.Commands) -> Unit, ) { Column( @@ -525,22 +525,22 @@ fun Buttons( if(postPro){ ItemButton( text = annotatedStringResource( - when (subscriptionState.type) { - is SubscriptionType.Active -> Phrase.from( + when (proDataState.type) { + is ProStatus.Active -> Phrase.from( LocalContext.current, R.string.sessionProBeta ) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format().toString() - is SubscriptionType.NeverSubscribed -> Phrase.from( + is ProStatus.NeverSubscribed -> Phrase.from( LocalContext.current, R.string.upgradeSession ) .put(APP_NAME_KEY, stringResource(R.string.app_name)) .format().toString() - is SubscriptionType.Expired -> Phrase.from( + is ProStatus.Expired -> Phrase.from( LocalContext.current, R.string.proRenewBeta ) @@ -556,49 +556,10 @@ fun Buttons( contentDescription = null, ) }, - endIcon = { - when(subscriptionState.refreshState){ - is State.Loading -> { - Box( - modifier = Modifier.size(LocalDimensions.current.itemButtonIconSpacing) - ) { - SmallCircularProgressIndicator( - modifier = Modifier.align(Alignment.Center), - color = LocalColors.current.text - ) - } - } - - is State.Error -> { - Box( - modifier = Modifier.size(LocalDimensions.current.itemButtonIconSpacing) - ) { - Icon( - painter = painterResource(id = R.drawable.ic_triangle_alert), - tint = LocalColors.current.warning, - contentDescription = stringResource(id = R.string.qa_icon_error), - modifier = Modifier - .size(LocalDimensions.current.iconMedium) - .align(Alignment.Center), - ) - } - } - - else -> null - } - }, modifier = Modifier.qaTag(R.string.qa_settings_item_pro), colors = accentTextButtonColors() ) { - // there is a special case when we have a subscription error or loading - // but also no pro account - if(subscriptionState.refreshState !is State.Success && - subscriptionState.type is SubscriptionType.NeverSubscribed - ){ - sendCommand(ShowProErrorOrLoading) - } else { - activity?.push() - } + activity?.push() } Divider() @@ -1031,14 +992,13 @@ fun AvatarDialog( @Composable fun AnimatedProCTA( - isPro: Boolean, + proSubscription: ProStatus, sendCommand: (SettingsViewModel.Commands) -> Unit, ){ - if(isPro) { - AnimatedSessionProActivatedCTA ( - heroImageBg = R.drawable.cta_hero_animated_bg, - heroImageAnimatedFg = R.drawable.cta_hero_animated_fg, + if(proSubscription is ProStatus.Active) { + SessionProCTA ( title = stringResource(R.string.proActivated), + badgeAtStart = true, textContent = { ProBadgeText( modifier = Modifier.align(Alignment.CenterHorizontally), @@ -1050,7 +1010,9 @@ fun AnimatedProCTA( // main message Text( - modifier = Modifier.align(Alignment.CenterHorizontally), + modifier = Modifier + .qaTag(R.string.qa_cta_body) + .align(Alignment.CenterHorizontally), text = stringResource(R.string.proAnimatedDisplayPicture), textAlign = TextAlign.Center, style = LocalType.current.base.copy( @@ -1058,10 +1020,19 @@ fun AnimatedProCTA( ) ) }, + content = { + CTAAnimatedImages( + heroImageBg = R.drawable.cta_hero_animated_bg, + heroImageAnimatedFg = R.drawable.cta_hero_animated_fg, + ) + }, + positiveButtonText = null, + negativeButtonText = stringResource(R.string.close), onCancel = { sendCommand(HideAnimatedProCTA) } ) } else { AnimatedProfilePicProCTA( + proSubscription = proSubscription, onDismissRequest = { sendCommand(HideAnimatedProCTA) }, ) } @@ -1091,23 +1062,11 @@ private fun SettingsScreenPreview() { ) ) ), - isPro = true, isPostPro = true, - subscriptionState = SubscriptionState( - type = SubscriptionType.Active.AutoRenewing( - proStatus = ProStatus.Pro( - visible = true, - validUntil = Instant.now() + Duration.ofDays(14), - ), - duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = SubscriptionType.Active.NonOriginatingSubscription( - device = "iPhone", - store = "Apple App Store", - platform = "Apple", - platformAccount = "Apple Account", - urlSubscription = "https://www.apple.com/account/subscriptions", - )), + proDataState = ProDataState( + type = previewAutoRenewingApple, refreshState = State.Success(Unit), + showProBadge = true ), username = "Atreyu", accountID = "053d30141d0d35d9c4b30a8f8880f8464e221ee71a8aff9f0dcefb1e60145cea5144", @@ -1124,96 +1083,6 @@ private fun SettingsScreenPreview() { } } -@OptIn(ExperimentalSharedTransitionApi::class) -@SuppressLint("UnusedContentLambdaTargetStateParameter") -@Preview -@Composable -private fun SettingsScreenNoProPreview() { - PreviewTheme { - Settings ( - uiState = SettingsViewModel.UIState( - showLoader = false, - avatarDialogState = SettingsViewModel.AvatarDialogState.NoAvatar, - recoveryHidden = false, - showUrlDialog = null, - showAvatarDialog = false, - showAvatarPickerOptionCamera = false, - showAvatarPickerOptions = false, - showAnimatedProCTA = false, - avatarData = AvatarUIData( - listOf( - AvatarUIElement( - name = "TO", - color = primaryBlue - ) - ) - ), - isPro = false, - isPostPro = true, - subscriptionState = SubscriptionState( - type = SubscriptionType.NeverSubscribed, - refreshState = State.Loading, - ), - username = "Atreyu", - accountID = "053d30141d0d35d9c4b30a8f8880f8464e221ee71a8aff9f0dcefb1e60145cea5144", - hasPath = true, - version = "1.26.0", - ), - sendCommand = {}, - onGalleryPicked = {}, - onCameraPicked = {}, - startAvatarSelection = {}, - onBack = {}, - - ) - } -} - -@OptIn(ExperimentalSharedTransitionApi::class) -@SuppressLint("UnusedContentLambdaTargetStateParameter") -@Preview -@Composable -private fun SettingsScreenProExpiredPreview() { - PreviewTheme { - Settings ( - uiState = SettingsViewModel.UIState( - showLoader = false, - avatarDialogState = SettingsViewModel.AvatarDialogState.NoAvatar, - recoveryHidden = false, - showUrlDialog = null, - showAvatarDialog = false, - showAvatarPickerOptionCamera = false, - showAvatarPickerOptions = false, - showAnimatedProCTA = false, - avatarData = AvatarUIData( - listOf( - AvatarUIElement( - name = "TO", - color = primaryBlue - ) - ) - ), - isPro = true, - isPostPro = true, - subscriptionState = SubscriptionState( - type = SubscriptionType.NeverSubscribed, - refreshState = State.Error(Exception()), - ), - username = "Atreyu", - accountID = "053d30141d0d35d9c4b30a8f8880f8464e221ee71a8aff9f0dcefb1e60145cea5144", - hasPath = true, - version = "1.26.0", - ), - sendCommand = {}, - onGalleryPicked = {}, - onCameraPicked = {}, - startAvatarSelection = {}, - onBack = {}, - - ) - } -} - @Preview @Composable fun PreviewAvatarDialog( diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt index ed138aab7e..f9fb3fad79 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt @@ -38,13 +38,10 @@ import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.utilities.await -import org.session.libsession.utilities.NonTranslatableStringConstants -import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.VERSION_KEY import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.displayName -import org.session.libsession.utilities.recipients.isPro import org.session.libsignal.utilities.ExternalStorageUtil.getImageDir import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.NoExternalStorageException @@ -54,9 +51,9 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.textSi import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.mms.MediaConstraints -import org.thoughtcrime.securesms.mms.PushMediaConstraints +import org.thoughtcrime.securesms.pro.ProDataState +import org.thoughtcrime.securesms.pro.ProDetailsRepository import org.thoughtcrime.securesms.pro.ProStatusManager -import org.thoughtcrime.securesms.pro.SubscriptionState import org.thoughtcrime.securesms.pro.getDefaultSubscriptionStateData import org.thoughtcrime.securesms.reviews.InAppReviewManager import org.thoughtcrime.securesms.ui.SimpleDialogData @@ -64,8 +61,9 @@ import org.thoughtcrime.securesms.util.AnimatedImageUtils import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUtils import org.thoughtcrime.securesms.util.ClearDataUtils +import org.thoughtcrime.securesms.util.DonationManager +import org.thoughtcrime.securesms.util.DonationManager.Companion.URL_DONATE import org.thoughtcrime.securesms.util.NetworkConnectivity -import org.thoughtcrime.securesms.util.State import org.thoughtcrime.securesms.util.mapToStateFlow import java.io.File import java.io.IOException @@ -86,6 +84,8 @@ class SettingsViewModel @Inject constructor( private val inAppReviewManager: InAppReviewManager, private val avatarUploadManager: AvatarUploadManager, private val attachmentProcessor: AttachmentProcessor, + private val proDetailsRepository: ProDetailsRepository, + private val donationManager: DonationManager, ) : ViewModel() { private val TAG = "SettingsViewModel" @@ -100,9 +100,8 @@ class SettingsViewModel @Inject constructor( hasPath = true, version = getVersionNumber(), recoveryHidden = prefs.getHidePassword(), - isPro = selfRecipient.value.proStatus.isPro(), isPostPro = proStatusManager.isPostPro(), - subscriptionState = getDefaultSubscriptionStateData(), + proDataState = getDefaultSubscriptionStateData(), )) val uiState: StateFlow get() = _uiState @@ -115,7 +114,6 @@ class SettingsViewModel @Inject constructor( _uiState.update { it.copy( username = recipient.displayName(attachesBlindedId = false), - isPro = recipient.proStatus.isPro(), ) } } @@ -123,8 +121,8 @@ class SettingsViewModel @Inject constructor( // observe subscription status viewModelScope.launch { - proStatusManager.subscriptionState.collect { state -> - _uiState.update { it.copy(subscriptionState = state) } + proStatusManager.proDataState.collect { state -> + _uiState.update { it.copy(proDataState = state) } } } @@ -159,6 +157,11 @@ class SettingsViewModel @Inject constructor( _uiState.update { it.copy(avatarData = data) } } } + + // refreshes the pro details data + viewModelScope.launch { + proDetailsRepository.requestRefresh() + } } private fun getVersionNumber(): CharSequence { @@ -268,7 +271,7 @@ class SettingsViewModel @Inject constructor( ?: return Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show() // if the selected avatar is animated but the user isn't pro, show the animated pro CTA - if (tempAvatar.isAnimated && !selfRecipient.value.proStatus.isPro() && proStatusManager.isPostPro()) { + if (tempAvatar.isAnimated && !selfRecipient.value.isPro && proStatusManager.isPostPro()) { showAnimatedProCTA() return } @@ -396,7 +399,7 @@ class SettingsViewModel @Inject constructor( private fun clearData(clearNetwork: Boolean) { val currentClearState = uiState.value.clearDataDialog - val isPro = selfRecipient.value.proStatus.isPro() + val isPro = selfRecipient.value.isPro // show loading _uiState.update { it.copy(clearDataDialog = ClearDataState.Clearing) } @@ -597,66 +600,29 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { inAppReviewManager.onEvent(InAppReviewManager.Event.DonateButtonClicked) } - showUrlDialog( "https://session.foundation/donate#app") - } - - is Commands.ShowProErrorOrLoading -> { - when(_uiState.value.subscriptionState.refreshState){ - // if we are in a loading or refresh state we should show a dialog instead - is State.Loading -> { - _uiState.update { - it.copy( - showSimpleDialog = SimpleDialogData( - title = Phrase.from(context.getText(R.string.proStatusLoading)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString(), - message = Phrase.from(context.getText(R.string.proStatusLoadingDescription)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format(), - positiveText = context.getString(R.string.okay), - positiveStyleDanger = false, - ) - ) - } - } + showUrlDialog(URL_DONATE) + } - is State.Error -> { - _uiState.update { - it.copy( - showSimpleDialog = SimpleDialogData( - title = Phrase.from(context.getText(R.string.proStatusError)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString(), - message = Phrase.from(context.getText(R.string.proStatusRefreshNetworkError)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format(), - positiveText = context.getString(R.string.retry), - negativeText = context.getString(R.string.helpSupport), - positiveStyleDanger = false, - showXIcon = true, - onPositive = { refreshSubscriptionData() }, - onNegative = { - showUrlDialog(ProStatusManager.URL_PRO_SUPPORT) - } - ) - ) - } - } + is Commands.HideSimpleDialog -> { + _uiState.update { it.copy(showSimpleDialog = null) } + } - else -> {} + is Commands.OnLinkOpened -> { + // if the link was for donation, mark it as seen + if(command.url == URL_DONATE) { + donationManager.onDonationSeen() } } - is Commands.HideSimpleDialog -> { - _uiState.update { it.copy(showSimpleDialog = null) } + is Commands.OnLinkCopied -> { + // if the link was for donation, mark it as seen + if(command.url == URL_DONATE) { + donationManager.onDonationCopied() + } } } } - private fun refreshSubscriptionData(){ - //todo PRO implement properly - } - sealed class AvatarDialogState() { object NoAvatar : AvatarDialogState() data class UserAvatar(val data: AvatarUIData) : AvatarDialogState() @@ -704,9 +670,8 @@ class SettingsViewModel @Inject constructor( val showAnimatedProCTA: Boolean = false, val usernameDialog: UsernameDialogData? = null, val showSimpleDialog: SimpleDialogData? = null, - val isPro: Boolean, val isPostPro: Boolean, - val subscriptionState: SubscriptionState, + val proDataState: ProDataState, ) sealed interface Commands { @@ -733,8 +698,9 @@ class SettingsViewModel @Inject constructor( data object OnDonateClicked: Commands - data object ShowProErrorOrLoading: Commands - data class ClearData(val clearNetwork: Boolean): Commands + + data class OnLinkOpened(val url: String) : Commands + data class OnLinkCopied(val url: String) : Commands } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/Util.kt index a136dab26b..c581f731ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/Util.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/Util.kt @@ -10,15 +10,17 @@ import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.ACCOUNT_ID_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DOWNLOAD_URL_KEY -import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.ApplicationContext fun Context.sendInvitationToUseSession() { val DOWNLOAD_URL = "https://getsession.org/download" + val accountId = (applicationContext as ApplicationContext).loginStateRepository.get().requireLocalNumber() + val txt = Phrase.from(getString(R.string.accountIdShare)) .put(APP_NAME_KEY, getString(R.string.app_name)) - .put(ACCOUNT_ID_KEY, TextSecurePreferences.getLocalNumber(this@sendInvitationToUseSession)) + .put(ACCOUNT_ID_KEY, accountId) .put(DOWNLOAD_URL_KEY, DOWNLOAD_URL) .format().toString() @@ -34,8 +36,9 @@ fun Context.sendInvitationToUseSession() { } fun Context.copyPublicKey() { + val accountId = (applicationContext as ApplicationContext).loginStateRepository.get().requireLocalNumber() val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Account ID", TextSecurePreferences.getLocalNumber(this)) + val clip = ClipData.newPlainText("Account ID", accountId) clipboard.setPrimaryClip(clip) Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsActivity.kt index fb1a9847d3..e80d58b85c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsActivity.kt @@ -1,7 +1,7 @@ package org.thoughtcrime.securesms.preferences.appearance import androidx.compose.runtime.Composable -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import dagger.hilt.android.AndroidEntryPoint import org.thoughtcrime.securesms.FullComposeScreenLockActivity diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt index cc0b28dce0..2e50075736 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt @@ -1,46 +1,55 @@ package org.thoughtcrime.securesms.preferences.prosettings import androidx.annotation.DrawableRes +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource 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 network.loki.messenger.R import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.DialogBg import org.thoughtcrime.securesms.ui.SessionProSettingsHeader import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.DangerFillButtonRect import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.components.inlineContentMap import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -48,8 +57,6 @@ import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.ui.theme.bold -import network.loki.messenger.R -import org.thoughtcrime.securesms.ui.DialogBg /** * Base structure used in most Pro Settings screen @@ -58,42 +65,69 @@ import org.thoughtcrime.securesms.ui.DialogBg @Composable fun BaseProSettingsScreen( disabled: Boolean, + hideHomeAppBar: Boolean = false, + listState: LazyListState = rememberLazyListState(), onBack: () -> Unit, onHeaderClick: (() -> Unit)? = null, extraHeaderContent: @Composable (() -> Unit)? = null, - content: @Composable () -> Unit + content: @Composable LazyItemScope.() -> Unit ){ + // Calculate scroll fraction + val density = LocalDensity.current + val thresholdPx = remember(density) { with(density) { 28.dp.toPx() } } // amount before the appbar gets fully opaque + + // raw fraction 0..1 derived from scrolling + val rawFraction by remember { + derivedStateOf { + when { + listState.layoutInfo.totalItemsCount == 0 -> 0f + listState.firstVisibleItemIndex > 0 -> 1f + else -> (listState.firstVisibleItemScrollOffset / thresholdPx).coerceIn(0f, 1f) + } + } + } + + // easing + smoothing of fraction + val easedFraction = remember(rawFraction) { + FastOutSlowInEasing.transform(rawFraction) + } + + // setting the appbar's bg alpha based on scroll + val backgroundColor = LocalColors.current.background.copy(alpha = easedFraction) + Scaffold( - topBar = { - BackAppBar( - title = "", - backgroundColor = Color.Transparent, - onBack = onBack, - ) - }, - contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + topBar = if(!hideHomeAppBar){{ + BackAppBar( + title = "", + backgroundColor = backgroundColor, + onBack = onBack, + ) + }} else {{}}, + contentWindowInsets = WindowInsets.systemBars, ) { paddings -> - - Column( + LazyColumn( modifier = Modifier - .fillMaxSize() - .padding(top = paddings.calculateTopPadding() - LocalDimensions.current.appBarHeight) - .consumeWindowInsets(paddings) - .padding( - horizontal = LocalDimensions.current.spacing, - ) - .verticalScroll(rememberScrollState()), + .fillMaxWidth() + .consumeWindowInsets(paddings), + state = listState, + contentPadding = PaddingValues( + start = LocalDimensions.current.spacing, + end = LocalDimensions.current.spacing, + top = (paddings.calculateTopPadding() - LocalDimensions.current.appBarHeight) + .coerceAtLeast(0.dp) + 46.dp, + bottom = paddings.calculateBottomPadding() + LocalDimensions.current.spacing + ), horizontalAlignment = CenterHorizontally ) { - Spacer(Modifier.height(46.dp)) - - SessionProSettingsHeader( - disabled = disabled, - onClick = onHeaderClick, - extraContent = extraHeaderContent - ) + item { + SessionProSettingsHeader( + disabled = disabled, + onClick = onHeaderClick, + extraContent = extraHeaderContent + ) + } - content() + item { content() } } } } @@ -106,11 +140,11 @@ fun BaseProSettingsScreen( fun BaseCellButtonProSettingsScreen( disabled: Boolean, onBack: () -> Unit, - buttonText: String, + buttonText: String?, dangerButton: Boolean, onButtonClick: () -> Unit, title: CharSequence? = null, - content: @Composable () -> Unit + content: @Composable LazyItemScope.() -> Unit ) { BaseProSettingsScreen( disabled = disabled, @@ -142,20 +176,22 @@ fun BaseCellButtonProSettingsScreen( Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) - if(dangerButton) { - DangerFillButtonRect( - modifier = Modifier.fillMaxWidth() - .widthIn(max = LocalDimensions.current.maxContentWidth), - text = buttonText, - onClick = onButtonClick - ) - } else { - AccentFillButtonRect( - modifier = Modifier.fillMaxWidth() - .widthIn(max = LocalDimensions.current.maxContentWidth), - text = buttonText, - onClick = onButtonClick - ) + if(buttonText != null) { + if (dangerButton) { + DangerFillButtonRect( + modifier = Modifier.fillMaxWidth() + .widthIn(max = LocalDimensions.current.maxContentWidth), + text = buttonText, + onClick = onButtonClick + ) + } else { + AccentFillButtonRect( + modifier = Modifier.fillMaxWidth() + .widthIn(max = LocalDimensions.current.maxContentWidth), + text = buttonText, + onClick = onButtonClick + ) + } } } } @@ -191,12 +227,13 @@ private fun PreviewBaseCellButton( fun BaseNonOriginatingProSettingsScreen( disabled: Boolean, onBack: () -> Unit, - buttonText: String, + buttonText: String?, dangerButton: Boolean, onButtonClick: () -> Unit, headerTitle: CharSequence?, contentTitle: String?, contentDescription: CharSequence?, + contentClick: (() -> Unit)? = null, linkCellsInfo: String?, linkCells: List = emptyList(), ) { @@ -219,9 +256,22 @@ fun BaseNonOriginatingProSettingsScreen( if (contentDescription != null) { Spacer(Modifier.height(LocalDimensions.current.xxxsSpacing)) Text( + modifier = Modifier.then( + // make the component clickable is there is an action + if (contentClick != null) Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = contentClick + ) + else Modifier + ), text = annotatedStringResource(contentDescription), style = LocalType.current.base, color = LocalColors.current.text, + inlineContent = inlineContentMap( + textSize = LocalType.current.base.fontSize, + imageColor = LocalColors.current.text, + ), ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt new file mode 100644 index 0000000000..1aba04d45c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.preferences.prosettings + +import android.widget.Toast +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator +import org.thoughtcrime.securesms.util.State + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BaseStateProScreen( + state: State, + onBack: () -> Unit, + successContent: @Composable (T) -> Unit +) { + // in the case of an error + val context = LocalContext.current + LaunchedEffect(state) { + if (state is State.Error) { + // show a toast and go back to pro settings home screen + Toast.makeText(context, R.string.errorGeneric, Toast.LENGTH_LONG).show() + onBack() + } + } + + when (state) { + is State.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + ) { + BackAppBar(title = "", onBack = onBack) + + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + } + + is State.Success -> successContent(state.value) + + else -> {} + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt new file mode 100644 index 0000000000..0cee7d8c4d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt @@ -0,0 +1,98 @@ +package org.thoughtcrime.securesms.preferences.prosettings + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import network.loki.messenger.libsession_util.protocol.PaymentProviderMetadata +import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.DEVICE_TYPE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACCOUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog +import org.thoughtcrime.securesms.pro.getPlatformDisplayName +import org.thoughtcrime.securesms.pro.previewAppleMetaData +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) +@Composable +fun CancelPlanNonOriginating( + providerData: PaymentProviderMetadata, + sendCommand: (ProSettingsViewModel.Commands) -> Unit, + onBack: () -> Unit, +){ + val context = LocalContext.current + + BaseNonOriginatingProSettingsScreen( + disabled = true, + onBack = onBack, + headerTitle = Phrase.from(context.getText(R.string.proCancelSorry)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), + buttonText = Phrase.from(context.getText(R.string.openPlatformWebsite)) + .put(PLATFORM_KEY, providerData.getPlatformDisplayName()) + .format().toString(), + dangerButton = true, + onButtonClick = { + sendCommand(ShowOpenUrlDialog(providerData.cancelSubscriptionUrl)) + }, + contentTitle = stringResource(R.string.proCancellation), + contentDescription = Phrase.from(context.getText(R.string.proCancellationDescription)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PLATFORM_ACCOUNT_KEY, providerData.platformAccount) + .format(), + linkCellsInfo = stringResource(R.string.proCancellationOptions), + linkCells = listOf( + NonOriginatingLinkCellData( + title = Phrase.from(context.getText(R.string.onDevice)) + .put(DEVICE_TYPE_KEY, providerData.device) + .format(), + info = Phrase.from(context.getText(R.string.onDeviceCancelDescription)) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .put(DEVICE_TYPE_KEY, providerData.device) + .put(PLATFORM_ACCOUNT_KEY, providerData.platformAccount) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format(), + iconRes = R.drawable.ic_smartphone + ), + NonOriginatingLinkCellData( + title = Phrase.from(context.getText(R.string.onPlatformWebsite)) + .put(PLATFORM_KEY, providerData.getPlatformDisplayName()) + .format(), + info = Phrase.from(context.getText(R.string.requestRefundPlatformWebsite)) + .put(PLATFORM_KEY, providerData.getPlatformDisplayName()) + .put(PLATFORM_ACCOUNT_KEY, providerData.platformAccount) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format(), + iconRes = R.drawable.ic_globe + ) + ) + ) +} + +@Preview +@Composable +private fun PreviewUpdatePlan( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + val context = LocalContext.current + CancelPlanNonOriginating ( + providerData = previewAppleMetaData, + sendCommand = {}, + onBack = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt new file mode 100644 index 0000000000..924c9059e9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt @@ -0,0 +1,155 @@ +package org.thoughtcrime.securesms.preferences.prosettings + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.OpenCancelSubscriptionPage +import org.thoughtcrime.securesms.pro.isFromAnotherPlatform +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.bold + + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun CancelPlanScreen( + viewModel: ProSettingsViewModel, + onBack: () -> Unit, +) { + LaunchedEffect(Unit) { + // ensuring we get the latest data here + // since we can deep link to this screen without going through the pro home screen + viewModel.ensureCancelState() + } + + val state by viewModel.cancelPlanState.collectAsState() + + BaseStateProScreen( + state = state, + onBack = onBack + ){ planData -> + val activePlan = planData.proStatus + + // there are different UI depending on the state + when { + // there is an active subscription but from a different platform or from the + // same platform but a different account + activePlan.providerData.isFromAnotherPlatform() + || !planData.hasValidSubscription -> + CancelPlanNonOriginating( + providerData = activePlan.providerData, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + + // default cancel screen + else -> CancelPlan( + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) +@Composable +fun CancelPlan( + sendCommand: (ProSettingsViewModel.Commands) -> Unit, + onBack: () -> Unit, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + // to track if the user came back from the cancel screen in the subscriber's page + var waitingForReturn by rememberSaveable { mutableStateOf(false) } + + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + if (waitingForReturn) { + waitingForReturn = false + sendCommand(ProSettingsViewModel.Commands.OnUserBackFromCancellation) + } + } + + BaseCellButtonProSettingsScreen( + disabled = true, + onBack = onBack, + buttonText = Phrase.from(context.getText(R.string.cancelProPlan)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), + dangerButton = true, + onButtonClick = { + waitingForReturn = true + sendCommand(OpenCancelSubscriptionPage) + }, + title = Phrase.from(context.getText(R.string.proCancelSorry)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), + ){ + Column { + Text( + text = stringResource(R.string.proCancellation), + style = LocalType.current.base.bold(), + color = LocalColors.current.text, + ) + + Spacer(Modifier.height(LocalDimensions.current.xxxsSpacing)) + + Text( + text = annotatedStringResource( + Phrase.from(context.getText(R.string.proCancellationShortDescription)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + ), + style = LocalType.current.base, + color = LocalColors.current.text, + ) + } + } +} + +@Preview +@Composable +private fun PreviewCancelPlan( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + CancelPlan( + sendCommand = {}, + onBack = {}, + ) + } +} + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt index 460acc41db..c8d596e956 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.preferences.prosettings +import androidx.activity.compose.BackHandler import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -31,13 +32,15 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import com.squareup.phrase.Phrase import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.NonTranslatableStringConstants.NETWORK_NAME import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NETWORK_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY -import org.session.libsession.utilities.recipients.ProStatus -import org.thoughtcrime.securesms.pro.SubscriptionState -import org.thoughtcrime.securesms.pro.SubscriptionType -import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.thoughtcrime.securesms.pro.ProDataState +import org.thoughtcrime.securesms.pro.ProStatus +import org.thoughtcrime.securesms.pro.previewAutoRenewingApple +import org.thoughtcrime.securesms.pro.previewExpiredApple import org.thoughtcrime.securesms.ui.SessionProSettingsHeader import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.annotatedStringResource @@ -49,8 +52,6 @@ import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.util.State -import java.time.Duration -import java.time.Instant @OptIn(ExperimentalSharedTransitionApi::class) @@ -75,6 +76,10 @@ fun PlanConfirmation( sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ) { + BackHandler { + sendCommand(ProSettingsViewModel.Commands.OnPostPlanConfirmation) + } + Scaffold( topBar = {}, contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), @@ -108,17 +113,34 @@ fun PlanConfirmation( Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + val description = when (proData.proDataState.type) { + is ProStatus.Active -> { + Phrase.from(context.getText(R.string.proAllSetDescription)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(DATE_KEY, proData.subscriptionExpiryDate) + .format() + } + + is ProStatus.NeverSubscribed -> { + Phrase.from(context.getText(R.string.proUpgraded)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(NETWORK_NAME_KEY, NETWORK_NAME) + .format() + } + + is ProStatus.Expired -> { + Phrase.from(context.getText(R.string.proPlanRenewSupport)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(NETWORK_NAME_KEY, NETWORK_NAME) + .format() + } + } + Text( modifier = Modifier.align(CenterHorizontally) .safeContentWidth(), - //todo PRO the text below can change if the user was renewing vs expiring and/or/auto-renew - text = annotatedStringResource( - Phrase.from(context.getText(R.string.proAllSetDescription)) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(DATE_KEY, proData.subscriptionExpiryDate) - .format() - ), + text = annotatedStringResource(description), textAlign = TextAlign.Center, style = LocalType.current.base, color = LocalColors.current.text, @@ -126,12 +148,24 @@ fun PlanConfirmation( Spacer(Modifier.height(LocalDimensions.current.spacing)) - //todo PRO the button text can change if the user was renewing vs expiring and/or/auto-renew + val buttonLabel = when (proData.proDataState.type) { + is ProStatus.Active -> stringResource(R.string.theReturn) + + else -> { + Phrase.from(context.getText(R.string.proStartUsing)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + .toString() + } + } + AccentFillButtonRect( modifier = Modifier.fillMaxWidth() .widthIn(max = LocalDimensions.current.maxContentWidth), - text = stringResource(R.string.theReturn), - onClick = {} + text = buttonLabel, + onClick = { + sendCommand(ProSettingsViewModel.Commands.OnPostPlanConfirmation) + } ) Spacer(Modifier.weight(1f)) @@ -142,22 +176,38 @@ fun PlanConfirmation( @Preview @Composable -private fun PreviewPlanConfirmation( +private fun PreviewPlanConfirmationActive( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + PlanConfirmation( + proData = ProSettingsViewModel.ProSettingsState( + subscriptionExpiryDate = "20th June 2026", + proDataState = ProDataState( + type = previewAutoRenewingApple, + refreshState = State.Success(Unit), + showProBadge = false, + ), + ), + sendCommand = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewPlanConfirmationExpired( @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors ) { PreviewTheme(colors) { PlanConfirmation( proData = ProSettingsViewModel.ProSettingsState( - subscriptionState = SubscriptionState( - type = SubscriptionType.Active.AutoRenewing( - proStatus = ProStatus.Pro( - visible = true, - validUntil = Instant.now() + Duration.ofDays(14), - ), - duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = null + proDataState = ProDataState( + type = previewExpiredApple, + refreshState = State.Success(Unit), + showProBadge = true, ), - refreshState = State.Success(Unit),), ), sendCommand = {}, onBack = {}, @@ -165,4 +215,26 @@ private fun PreviewPlanConfirmation( } } +@Preview +@Composable +private fun PreviewPlanConfirmationNeverSub( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + PlanConfirmation( + proData = ProSettingsViewModel.ProSettingsState( + proDataState = ProDataState( + type = ProStatus.NeverSubscribed, + refreshState = State.Success(Unit), + showProBadge = true, + ), + ), + sendCommand = {}, + onBack = {}, + ) + } +} + + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt index 0b3ca340a4..1d6fdff0cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt @@ -1,21 +1,39 @@ package org.thoughtcrime.securesms.preferences.prosettings +import android.content.Context +import android.content.Intent import androidx.compose.runtime.Composable +import androidx.core.content.IntentCompat import dagger.hilt.android.AndroidEntryPoint import org.thoughtcrime.securesms.FullComposeScreenLockActivity -import org.thoughtcrime.securesms.ui.UINavigator -import javax.inject.Inject @AndroidEntryPoint class ProSettingsActivity: FullComposeScreenLockActivity() { - @Inject - lateinit var navigator: UINavigator + companion object { + private const val EXTRA_START_DESTINATION = "start_destination" + + fun createIntent( + context: Context, + startDestination: ProSettingsDestination = ProSettingsDestination.Home + ): Intent { + return Intent(context, ProSettingsActivity::class.java).apply { + putExtra(EXTRA_START_DESTINATION, startDestination) + } + } + } @Composable override fun ComposeContent() { + val startDestination = IntentCompat.getParcelableExtra( + intent, + EXTRA_START_DESTINATION, + ProSettingsDestination::class.java + ) ?: ProSettingsDestination.Home + ProSettingsNavHost( - navigator = navigator, + inSheet = false, + startDestination = startDestination, onBack = this::finish ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsDialogs.kt index 576e0e4870..d790d1e945 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsDialogs.kt @@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.ui.Divider import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.IconActionRowItem import org.thoughtcrime.securesms.ui.OpenURLAlertDialog +import org.thoughtcrime.securesms.ui.TCPolicyDialog import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -51,7 +52,9 @@ fun ProSettingsDialogs( // T&C + Policy dialog if(dialogsState.showTCPolicyDialog){ TCPolicyDialog( - sendCommand = sendCommand + tcsUrl = "https://getsession.org/pro/terms", + privacyUrl = "https://getsession.org/pro/privacy", + onDismissRequest = { sendCommand(HideTCPolicyDialog) }, ) } @@ -90,66 +93,4 @@ fun ProSettingsDialogs( buttons = buttons ) } -} - -@Composable -fun TCPolicyDialog( - sendCommand: (ProSettingsViewModel.Commands) -> Unit -){ - AlertDialog( - onDismissRequest = { sendCommand(HideTCPolicyDialog) }, - title = stringResource(R.string.urlOpen), - text = stringResource(R.string.urlOpenBrowser), - content = { - Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) - Cell( - bgColor = LocalColors.current.backgroundTertiary - ) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - val context = LocalContext.current - val spacing = LocalDimensions.current.xsSpacing - - val tcsUrl = "https://getsession.org/pro/terms" - IconActionRowItem( - title = annotatedStringResource(tcsUrl), - textStyle = LocalType.current.large.bold(), - icon = R.drawable.ic_square_arrow_up_right, - iconSize = LocalDimensions.current.iconSmall, - paddingValues = PaddingValues(start = spacing), - qaTag = R.string.AccessibilityId_onboardingTos, - onClick = { - context.openUrl(tcsUrl) - } - ) - Divider(paddingValues = PaddingValues(horizontal = spacing)) - val privacyUrl = "https://getsession.org/pro/privacy" - IconActionRowItem( - title = annotatedStringResource(privacyUrl), - textStyle = LocalType.current.large.bold(), - icon = R.drawable.ic_square_arrow_up_right, - iconSize = LocalDimensions.current.iconSmall, - paddingValues = PaddingValues(start = spacing), - qaTag = R.string.AccessibilityId_onboardingPrivacy, - onClick = { - context.openUrl(privacyUrl) - } - ) - } - } - } - ) -} - -@Preview -@Composable -private fun PreviewCPolicyDialog( - @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors -) { - PreviewTheme(colors) { - TCPolicyDialog( - sendCommand = {} - ) - } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index 171cf0020b..bdea2b9621 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -19,12 +19,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -39,7 +42,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString +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 @@ -50,14 +56,14 @@ import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.ICON_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY -import org.session.libsession.utilities.recipients.ProStatus -import org.session.libsession.utilities.recipients.shouldShowProBadge import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.* +import org.thoughtcrime.securesms.pro.ProDataState +import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.pro.ProStatusManager -import org.thoughtcrime.securesms.pro.SubscriptionType -import org.thoughtcrime.securesms.pro.SubscriptionState -import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.thoughtcrime.securesms.pro.previewAutoRenewingApple +import org.thoughtcrime.securesms.pro.previewExpiredApple import org.thoughtcrime.securesms.ui.ActionRowItem import org.thoughtcrime.securesms.ui.CategoryCell import org.thoughtcrime.securesms.ui.Divider @@ -65,6 +71,7 @@ import org.thoughtcrime.securesms.ui.IconActionRowItem import org.thoughtcrime.securesms.ui.ProBadgeText import org.thoughtcrime.securesms.ui.SpeechBubbleTooltip import org.thoughtcrime.securesms.ui.SwitchActionRowItem +import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.ExtraSmallCircularProgressIndicator import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator import org.thoughtcrime.securesms.ui.components.annotatedStringResource @@ -87,20 +94,33 @@ import org.thoughtcrime.securesms.ui.theme.primaryRed import org.thoughtcrime.securesms.ui.theme.primaryYellow import org.thoughtcrime.securesms.util.NumberUtil import org.thoughtcrime.securesms.util.State -import java.time.Duration -import java.time.Instant @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun ProSettingsHomeScreen( viewModel: ProSettingsViewModel, + inSheet: Boolean, + shouldScrollToTop: Boolean = false, + onScrollToTopConsumed: () -> Unit = {}, onBack: () -> Unit, ) { val data by viewModel.proSettingsUIState.collectAsState() + val listState = rememberLazyListState() + + // check if we requested to scroll to the top + LaunchedEffect(shouldScrollToTop) { + if (shouldScrollToTop) { + listState.scrollToItem(0) + onScrollToTopConsumed() + } + } + ProSettingsHome( data = data, + inSheet = inSheet, + listState = listState, sendCommand = viewModel::onCommand, onBack = onBack, ) @@ -110,31 +130,42 @@ fun ProSettingsHomeScreen( @Composable fun ProSettingsHome( data: ProSettingsViewModel.ProSettingsState, + inSheet: Boolean, + listState: LazyListState, sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ) { - val subscriptionType = data.subscriptionState.type + val subscriptionType = data.proDataState.type val context = LocalContext.current + val expiredInMainScreen = subscriptionType is ProStatus.Expired && !inSheet + val expiredInSheet = subscriptionType is ProStatus.Expired && inSheet + BaseProSettingsScreen( - disabled = subscriptionType is SubscriptionType.Expired, + disabled = expiredInMainScreen, + hideHomeAppBar = inSheet, + listState = listState, onBack = onBack, onHeaderClick = { // add a click handling if the subscription state is loading or errored - if(data.subscriptionState.refreshState !is State.Success<*>){ - sendCommand(OnHeaderClicked) + if(data.proDataState.refreshState !is State.Success<*>){ + sendCommand(OnHeaderClicked(inSheet)) } else null }, extraHeaderContent = { // display extra content if the subscription state is loading or errored - when(data.subscriptionState.refreshState){ + when(data.proDataState.refreshState){ is State.Loading -> { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing) ) { Text( - text = Phrase.from(context.getText(R.string.proStatusLoadingSubtitle)) + text = Phrase.from(context.getText( + when(subscriptionType){ + is ProStatus.Active -> R.string.proStatusLoadingSubtitle + else -> R.string.checkingProStatus + })) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString(), style = LocalType.current.base, @@ -152,7 +183,11 @@ fun ProSettingsHome( horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxxsSpacing) ) { Text( - text = Phrase.from(context.getText(R.string.proErrorRefreshingStatus)) + text = Phrase.from(context.getText( + when(subscriptionType){ + is ProStatus.Active -> R.string.proErrorRefreshingStatus + else -> R.string.errorCheckingProStatus + })) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString(), style = LocalType.current.base, @@ -173,8 +208,59 @@ fun ProSettingsHome( } } ) { + // Header for non-pro users or expired users in sheet mode + if(subscriptionType is ProStatus.NeverSubscribed || expiredInSheet) { + if(data.proDataState.refreshState !is State.Success){ + Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) + } + + Text( + text = if(expiredInSheet) Phrase.from(context.getText(R.string.proAccessRenewStart)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format().toString() + else Phrase.from(context.getText(R.string.proFullestPotential)) + .put(APP_NAME_KEY, stringResource(R.string.app_name)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format().toString(), + style = LocalType.current.base, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(LocalDimensions.current.spacing)) + + Box { + val enableButon = data.proDataState.refreshState is State.Success + AccentFillButtonRect( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.theContinue), + enabled = enableButon, + onClick = { sendCommand(GoToChoosePlan(inSheet)) } + ) + // the designs require we should still be able to click on the disabled button... + // this goes against the system the built in ux decisions. + // To avoid extending the button we will instead add a clickable area above the button, + // invisible to screen readers as this is purely a visual action in case people try to + // click in spite of the state being "loading" or "error" + if (!enableButon) { + Box( + modifier = Modifier.fillMaxWidth() + .height(LocalDimensions.current.minItemButtonHeight) + .semantics { + hideFromAccessibility() + } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { sendCommand(GoToChoosePlan(inSheet)) } + ) + ) { } + } + } + } + // Pro Stats - if(subscriptionType is SubscriptionType.Active){ + if(subscriptionType is ProStatus.Active){ Spacer(Modifier.height(LocalDimensions.current.spacing)) ProStats( data = data.proStats, @@ -183,21 +269,25 @@ fun ProSettingsHome( } // Pro account settings - if(subscriptionType is SubscriptionType.Active){ + if(subscriptionType is ProStatus.Active){ Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) ProSettings( - data = subscriptionType, - subscriptionRefreshState = data.subscriptionState.refreshState, + showProBadge = data.proDataState.showProBadge, + proStatus = data.proDataState.type, + subscriptionRefreshState = data.proDataState.refreshState, + inSheet = inSheet, expiry = data.subscriptionExpiryLabel, sendCommand = sendCommand, ) } // Manage Pro - Expired - if(subscriptionType is SubscriptionType.Expired){ + if(expiredInMainScreen){ Spacer(Modifier.height(LocalDimensions.current.spacing)) ProManage( data = subscriptionType, + subscriptionRefreshState = data.proDataState.refreshState, + inSheet = inSheet, sendCommand = sendCommand, ) } @@ -206,66 +296,18 @@ fun ProSettingsHome( Spacer(Modifier.height(LocalDimensions.current.spacing)) ProFeatures( data = subscriptionType, + disabled = expiredInMainScreen, sendCommand = sendCommand, ) - // Manage Pro - Pro - if(subscriptionType is SubscriptionType.Active){ - Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) - ProManage( - data = subscriptionType, - sendCommand = sendCommand, - ) - } - - // Help - Spacer(Modifier.height(LocalDimensions.current.spacing)) - CategoryCell( - title = stringResource(R.string.sessionHelp), - ) { - val iconColor = if(subscriptionType is SubscriptionType.Expired) LocalColors.current.text - else LocalColors.current.accentText - - // Cell content - Column( - modifier = Modifier.fillMaxWidth(), - ) { - IconActionRowItem( - title = annotatedStringResource( - Phrase.from(LocalContext.current, R.string.proFaq) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString() - ), - subtitle = annotatedStringResource( - Phrase.from(LocalContext.current, R.string.proFaqDescription) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format().toString() - ), - icon = R.drawable.ic_square_arrow_up_right, - iconSize = LocalDimensions.current.iconMedium, - iconColor = iconColor, - qaTag = R.string.qa_pro_settings_action_faq, - onClick = { - sendCommand(ShowOpenUrlDialog("https://getsession.org/faq#pro")) - } - ) - Divider() - IconActionRowItem( - title = annotatedStringResource(R.string.helpSupport), - subtitle = annotatedStringResource( - Phrase.from(LocalContext.current, R.string.proSupportDescription) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString() - ), - icon = R.drawable.ic_square_arrow_up_right, - iconSize = LocalDimensions.current.iconMedium, - iconColor = iconColor, - qaTag = R.string.qa_pro_settings_action_support, - onClick = { - sendCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) - } - ) - } + // do not display the footer in sheet mode + if(!inSheet){ + ProSettingsFooter( + proStatus = subscriptionType, + subscriptionRefreshState = data.proDataState.refreshState, + inSheet = inSheet, + sendCommand = sendCommand + ) } Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) @@ -277,7 +319,7 @@ fun ProSettingsHome( @Composable fun ProStats( modifier: Modifier = Modifier, - data: ProSettingsViewModel.ProStats, + data: State, sendCommand: (ProSettingsViewModel.Commands) -> Unit, ){ CategoryCell( @@ -315,9 +357,20 @@ fun ProStats( // Cell content Column( modifier = Modifier.fillMaxWidth() + .then( + // make the component clickable is we are in the loading state + if (data !is State.Success) Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { sendCommand(OnProStatsClicked) } + ) + else Modifier + ) .padding(LocalDimensions.current.smallSpacing), verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) ){ + val stats = (data as? State.Success)?.value + Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xsSpacing) @@ -327,9 +380,11 @@ fun ProStats( modifier = Modifier.weight(1f), title = pluralStringResource( R.plurals.proLongerMessagesSent, - data.longMessages, - NumberUtil.getFormattedNumber(data.longMessages.toLong()) - ), + stats?.longMessages ?: 0, + if(stats != null) NumberUtil.getFormattedNumber(stats.longMessages.toLong()) + else "" + ).trim(), + loading = data !is State.Success, icon = R.drawable.ic_message_square ) @@ -338,9 +393,11 @@ fun ProStats( modifier = Modifier.weight(1f), title = pluralStringResource( R.plurals.proPinnedConversations, - data.pinnedConversations, - NumberUtil.getFormattedNumber(data.pinnedConversations.toLong()) - ), + stats?.pinnedConversations ?: 0, + if(stats != null) NumberUtil.getFormattedNumber(stats.pinnedConversations.toLong()) + else "" + ).trim(), + loading = data !is State.Success, icon = R.drawable.ic_pin ) } @@ -354,10 +411,12 @@ fun ProStats( modifier = Modifier.weight(1f), title = pluralStringResource( R.plurals.proBadgesSent, - data.proBadges, - NumberUtil.getFormattedNumber(data.proBadges.toLong()), + stats?.proBadges ?: 0, + if(stats != null) NumberUtil.getFormattedNumber(stats.proBadges.toLong()) + else "", NonTranslatableStringConstants.PRO - ), + ).trim(), + loading = data !is State.Success, icon = R.drawable.ic_rectangle_ellipsis ) @@ -367,11 +426,13 @@ fun ProStats( modifier = Modifier.weight(1f), title = pluralStringResource( R.plurals.proGroupsUpgraded, - data.groupsUpdated, - NumberUtil.getFormattedNumber(data.groupsUpdated.toLong()) - ), + stats?.groupsUpdated ?: 0, + if(stats != null) NumberUtil.getFormattedNumber(stats.groupsUpdated.toLong()) + else "" + ).trim(), icon = R.drawable.ic_users_group_custom, disabled = true, + loading = data !is State.Success, tooltip = stringResource(R.string.proLargerGroupsTooltip) ) @@ -387,11 +448,14 @@ fun ProStatItem( title: String, @DrawableRes icon: Int, disabled: Boolean = false, + loading: Boolean = false, tooltip: String? = null, ){ val scope = rememberCoroutineScope() val tooltipState = rememberTooltipState(isPersistent = true) + val disabledState = disabled && !loading + Row( modifier = modifier.then( // make the component clickable is there is an edit action @@ -409,23 +473,27 @@ fun ProStatItem( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) ){ - Image( - painter = painterResource(id = icon), - contentDescription = null, - modifier = Modifier.size(32.dp), - colorFilter = ColorFilter.tint( - if(disabled) LocalColors.current.textSecondary else LocalColors.current.accent + if(loading){ + SmallCircularProgressIndicator() + } else { + Image( + painter = painterResource(id = icon), + contentDescription = null, + modifier = Modifier.size(32.dp), + colorFilter = ColorFilter.tint( + if (disabledState) LocalColors.current.textSecondary else LocalColors.current.accent + ) ) - ) + } Text( modifier = Modifier.weight(1f), text = title, style = LocalType.current.h9, - color = if(disabled) LocalColors.current.textSecondary else LocalColors.current.text + color = if(disabledState) LocalColors.current.textSecondary else LocalColors.current.text ) - if(tooltip != null){ + if(tooltip != null && !loading){ SpeechBubbleTooltip( text = tooltip, tooltipState = tooltipState @@ -451,8 +519,10 @@ fun ProStatItem( @Composable fun ProSettings( modifier: Modifier = Modifier, - data: SubscriptionType.Active, + showProBadge: Boolean, + proStatus: ProStatus.Active, subscriptionRefreshState: State, + inSheet: Boolean, expiry: CharSequence, sendCommand: (ProSettingsViewModel.Commands) -> Unit, ){ @@ -462,6 +532,8 @@ fun ProSettings( .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString(), ) { + val refunding = proStatus.refundInProgress + // Cell content Column( modifier = Modifier.fillMaxWidth(), @@ -477,9 +549,10 @@ fun ProSettings( ) } + val (subtitle, subColor, icon) = when(subscriptionRefreshState){ is State.Loading -> Triple Unit>( - Phrase.from(LocalContext.current, R.string.proPlanLoadingEllipsis) + Phrase.from(LocalContext.current, R.string.proAccessLoadingEllipsis) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString(), LocalColors.current.text, @@ -487,22 +560,43 @@ fun ProSettings( ) is State.Error -> Triple Unit>( - Phrase.from(LocalContext.current, R.string.errorLoadingProPlan) + Phrase.from(LocalContext.current, R.string.errorLoadingProAccess) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString(), LocalColors.current.warning, chevronIcon ) - is State.Success<*> -> Triple Unit>( - expiry, - LocalColors.current.text, chevronIcon - ) + is State.Success<*> -> { + Triple Unit>( + if(refunding) Phrase.from(LocalContext.current, R.string.processingRefundRequest) + .put(PLATFORM_KEY, proStatus.providerData.platform) + .format().toString() + else expiry, + LocalColors.current.text, + if(refunding){{ + Icon( + modifier = Modifier.align(Alignment.Center) + .size(LocalDimensions.current.iconMedium) + .qaTag(R.string.qa_action_item_icon), + painter = painterResource(id = R.drawable.ic_circle_warning_custom), + contentDescription = null, + tint = LocalColors.current.text + ) + }} else chevronIcon + ) + } } ActionRowItem( - title = annotatedStringResource(R.string.updatePlan), + title = if(refunding) annotatedStringResource(R.string.proRequestedRefund) + else annotatedStringResource( + Phrase.from(LocalContext.current, R.string.updateAccess) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() + ), subtitle = annotatedStringResource(subtitle), subtitleColor = subColor, + enabled = !refunding, endContent = { Box( modifier = Modifier.size(LocalDimensions.current.itemButtonIconSpacing) @@ -511,7 +605,7 @@ fun ProSettings( } }, qaTag = R.string.qa_pro_settings_action_update_plan, - onClick = { sendCommand(ShowPlanUpdate) } + onClick = { sendCommand(GoToChoosePlan(inSheet)) } ) Divider() @@ -526,7 +620,7 @@ fun ProSettings( .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format().toString() ), - checked = data.proStatus.shouldShowProBadge(), + checked = showProBadge, qaTag = R.string.qa_pro_settings_action_show_badge, onCheckedChange = { sendCommand(SetShowProBadge(it)) } ) @@ -537,7 +631,8 @@ fun ProSettings( @Composable fun ProFeatures( modifier: Modifier = Modifier, - data: SubscriptionType, + data: ProStatus, + disabled: Boolean, sendCommand: (ProSettingsViewModel.Commands) -> Unit, ) { CategoryCell( @@ -565,11 +660,12 @@ fun ProFeatures( // Longer messages ProFeatureItem( title = stringResource(R.string.proLongerMessages), - subtitle = annotatedStringResource(R.string.proLongerMessagesDescription), + subtitle = if(data is ProStatus.Active) annotatedStringResource(R.string.proLongerMessagesDescription) + else annotatedStringResource(R.string.nonProLongerMessagesDescription), icon = R.drawable.ic_message_square, iconGradientStart = primaryBlue, iconGradientEnd = primaryPurple, - expired = data is SubscriptionType.Expired + expired = disabled ) // Unlimited pins @@ -579,7 +675,7 @@ fun ProFeatures( icon = R.drawable.ic_pin, iconGradientStart = primaryPurple, iconGradientEnd = primaryPink, - expired = data is SubscriptionType.Expired + expired = disabled ) // Animated pics @@ -589,7 +685,7 @@ fun ProFeatures( icon = R.drawable.ic_square_play, iconGradientStart = primaryPink, iconGradientEnd = primaryRed, - expired = data is SubscriptionType.Expired + expired = disabled ) // Pro badges @@ -603,13 +699,13 @@ fun ProFeatures( icon = R.drawable.ic_rectangle_ellipsis, iconGradientStart = primaryRed, iconGradientEnd = primaryOrange, - expired = data is SubscriptionType.Expired, + expired = disabled, showProBadge = true, ) // More... ProFeatureItem( - title = stringResource(R.string.proFeatureListLoadsMore), + title = stringResource(R.string.plusLoadsMore), subtitle = annotatedStringResource( text = Phrase.from(LocalContext.current.getText(R.string.plusLoadsMoreDescription)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) @@ -620,7 +716,7 @@ fun ProFeatures( icon = R.drawable.ic_circle_plus, iconGradientStart = primaryOrange, iconGradientEnd = primaryYellow, - expired = data is SubscriptionType.Expired, + expired = disabled, onClick = { sendCommand(ShowOpenUrlDialog("https://getsession.org/pro-roadmap")) } @@ -698,7 +794,9 @@ private fun ProFeatureItem( @Composable fun ProManage( modifier: Modifier = Modifier, - data: SubscriptionType, + data: ProStatus, + inSheet: Boolean, + subscriptionRefreshState: State, sendCommand: (ProSettingsViewModel.Commands) -> Unit, ){ CategoryCell( @@ -711,80 +809,195 @@ fun ProManage( Column( modifier = Modifier.fillMaxWidth(), ) { + val refundButton: @Composable ()->Unit = { + IconActionRowItem( + title = annotatedStringResource(R.string.requestRefund), + titleColor = LocalColors.current.danger, + icon = R.drawable.ic_circle_warning_custom, + iconColor = LocalColors.current.danger, + qaTag = R.string.qa_pro_settings_action_request_refund, + onClick = { + sendCommand(GoToRefund) + } + ) + } + + val recoverButton: @Composable ()->Unit = { + IconActionRowItem( + title = annotatedStringResource( + Phrase.from(LocalContext.current, R.string.proAccessRecover) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() + ), + icon = R.drawable.ic_refresh_cw, + qaTag = R.string.qa_pro_settings_action_recover_plan, + onClick = { + sendCommand(RefeshProDetails) + } + ) + } + when(data){ - is SubscriptionType.Active.AutoRenewing -> { + is ProStatus.Active.AutoRenewing -> { IconActionRowItem( - title = annotatedStringResource(R.string.cancelPlan), + title = annotatedStringResource( + Phrase.from(LocalContext.current, R.string.cancelAccess) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() + ), titleColor = LocalColors.current.danger, icon = R.drawable.ic_circle_x_custom, iconColor = LocalColors.current.danger, qaTag = R.string.qa_pro_settings_action_cancel_plan, onClick = { - //todo PRO implement + sendCommand(GoToCancel) } ) Divider() - IconActionRowItem( - title = annotatedStringResource(R.string.requestRefund), - titleColor = LocalColors.current.danger, - icon = R.drawable.ic_circle_warning_custom, - iconColor = LocalColors.current.danger, - qaTag = R.string.qa_pro_settings_action_request_refund, - onClick = { - //todo PRO implement - } - ) + refundButton() } - is SubscriptionType.Active.Expiring -> { - IconActionRowItem( - title = annotatedStringResource(R.string.cancelPlan), - titleColor = LocalColors.current.danger, - icon = R.drawable.ic_circle_x_custom, - iconColor = LocalColors.current.danger, - qaTag = R.string.qa_pro_settings_action_cancel_plan, - onClick = { - //todo PRO implement - } - ) + is ProStatus.Active.Expiring -> { + refundButton() } - is SubscriptionType.Expired -> { - IconActionRowItem( - title = annotatedStringResource( - Phrase.from(LocalContext.current, R.string.proPlanRenew) + is ProStatus.NeverSubscribed -> { + recoverButton() + } + + is ProStatus.Expired -> { + // the details depend on the loading/error state + fun renewIcon(color: Color): @Composable BoxScope.() -> Unit = { + Icon( + modifier = Modifier.align(Alignment.Center) + .size(LocalDimensions.current.iconMedium) + .qaTag(R.string.qa_action_item_icon), + painter = painterResource(id = R.drawable.ic_circle_plus), + contentDescription = null, + tint = color + ) + } + + val (subtitle, subColor, icon) = when(subscriptionRefreshState){ + is State.Loading -> Triple Unit>( + Phrase.from(LocalContext.current, R.string.checkingProStatusEllipsis) .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString() - ), - titleColor = LocalColors.current.accentText, - icon = R.drawable.ic_circle_plus, - iconColor = LocalColors.current.accentText, - qaTag = R.string.qa_pro_settings_action_cancel_plan, - onClick = { - sendCommand(ShowPlanUpdate) - } - ) - Divider() - IconActionRowItem( + .format().toString(), + LocalColors.current.text, + { SmallCircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } + ) + + is State.Error -> Triple Unit>( + Phrase.from(LocalContext.current, R.string.errorCheckingProStatus) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), + LocalColors.current.warning, renewIcon(LocalColors.current.text) + ) + + is State.Success<*> -> Triple Unit>( + null, + LocalColors.current.text, renewIcon(LocalColors.current.accent) + ) + } + + ActionRowItem( title = annotatedStringResource( - Phrase.from(LocalContext.current, R.string.proPlanRecover) + Phrase.from(LocalContext.current, R.string.proAccessRenew) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() ), - icon = R.drawable.ic_refresh_cw, - qaTag = R.string.qa_pro_settings_action_request_refund, - onClick = { - //todo PRO implement - } + titleColor = if(subscriptionRefreshState is State.Success ) LocalColors.current.accent + else LocalColors.current.text, + subtitle = if(subtitle == null) null else annotatedStringResource(subtitle), + subtitleColor = subColor, + endContent = { + Box( + modifier = Modifier.size(LocalDimensions.current.itemButtonIconSpacing) + ) { + icon() + } + }, + qaTag = R.string.qa_pro_settings_action_renew_plan, + onClick = { sendCommand(GoToChoosePlan(inSheet)) } ) - } - is SubscriptionType.NeverSubscribed -> {} + Divider() + recoverButton() + } } } } } +@Composable +fun ProSettingsFooter( + proStatus: ProStatus, + subscriptionRefreshState: State, + inSheet: Boolean, + sendCommand: (ProSettingsViewModel.Commands) -> Unit, +) { + // Manage Pro - Expired has this in the header so exclude it here + if(proStatus !is ProStatus.Expired) { + Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) + ProManage( + data = proStatus, + inSheet = inSheet, + subscriptionRefreshState = subscriptionRefreshState, + sendCommand = sendCommand, + ) + } + + // Help + Spacer(Modifier.height(LocalDimensions.current.spacing)) + CategoryCell( + title = stringResource(R.string.sessionHelp), + ) { + val iconColor = if(proStatus is ProStatus.Expired) LocalColors.current.text + else LocalColors.current.accentText + + // Cell content + Column( + modifier = Modifier.fillMaxWidth(), + ) { + IconActionRowItem( + title = annotatedStringResource( + Phrase.from(LocalContext.current, R.string.proFaq) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() + ), + subtitle = annotatedStringResource( + Phrase.from(LocalContext.current, R.string.proFaqDescription) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format().toString() + ), + icon = R.drawable.ic_square_arrow_up_right, + iconSize = LocalDimensions.current.iconMedium, + iconColor = iconColor, + qaTag = R.string.qa_pro_settings_action_faq, + onClick = { + sendCommand(ShowOpenUrlDialog("https://getsession.org/faq#pro")) + } + ) + Divider() + IconActionRowItem( + title = annotatedStringResource(R.string.helpSupport), + subtitle = annotatedStringResource( + Phrase.from(LocalContext.current, R.string.proSupportDescription) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() + ), + icon = R.drawable.ic_square_arrow_up_right, + iconSize = LocalDimensions.current.iconMedium, + iconColor = iconColor, + qaTag = R.string.qa_pro_settings_action_support, + onClick = { + sendCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) + } + ) + } + } +} + @Preview @Composable fun PreviewProSettingsPro( @@ -793,19 +1006,15 @@ fun PreviewProSettingsPro( PreviewTheme(colors) { ProSettingsHome( data = ProSettingsViewModel.ProSettingsState( - subscriptionState = SubscriptionState( - type = SubscriptionType.Active.AutoRenewing( - proStatus = ProStatus.Pro( - visible = true, - validUntil = Instant.now() + Duration.ofDays(14), - ), - duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = null - ), + proDataState = ProDataState( + type = previewAutoRenewingApple, refreshState = State.Success(Unit), + showProBadge = true, ), ), + inSheet = false, sendCommand = {}, + listState = rememberLazyListState(), onBack = {}, ) } @@ -819,18 +1028,14 @@ fun PreviewProSettingsProLoading( PreviewTheme(colors) { ProSettingsHome( data = ProSettingsViewModel.ProSettingsState( - subscriptionState = SubscriptionState( - type = SubscriptionType.Active.AutoRenewing( - proStatus = ProStatus.Pro( - visible = true, - validUntil = Instant.now() + Duration.ofDays(14), - ), - duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = null - ), + proDataState = ProDataState( + type = previewAutoRenewingApple, refreshState = State.Loading, + showProBadge = true, ), ), + inSheet = false, + listState = rememberLazyListState(), sendCommand = {}, onBack = {}, ) @@ -845,18 +1050,14 @@ fun PreviewProSettingsProError( PreviewTheme(colors) { ProSettingsHome( data = ProSettingsViewModel.ProSettingsState( - subscriptionState = SubscriptionState( - type = SubscriptionType.Active.AutoRenewing( - proStatus = ProStatus.Pro( - visible = true, - validUntil = Instant.now() + Duration.ofDays(14), - ), - duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = null - ), + proDataState = ProDataState( + type = previewAutoRenewingApple, refreshState = State.Error(Exception()), + showProBadge = true, ), ), + inSheet = false, + listState = rememberLazyListState(), sendCommand = {}, onBack = {}, ) @@ -871,11 +1072,80 @@ fun PreviewProSettingsExpired( PreviewTheme(colors) { ProSettingsHome( data = ProSettingsViewModel.ProSettingsState( - subscriptionState = SubscriptionState( - type = SubscriptionType.Expired, + proDataState = ProDataState( + type = previewExpiredApple, refreshState = State.Success(Unit), + showProBadge = true, ) ), + inSheet = false, + listState = rememberLazyListState(), + sendCommand = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +fun PreviewProSettingsExpiredInSheet( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + ProSettingsHome( + data = ProSettingsViewModel.ProSettingsState( + proDataState = ProDataState( + type = previewExpiredApple, + refreshState = State.Success(Unit), + showProBadge = true, + ) + ), + inSheet = true, + listState = rememberLazyListState(), + sendCommand = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +fun PreviewProSettingsExpiredLoading( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + ProSettingsHome( + data = ProSettingsViewModel.ProSettingsState( + proDataState = ProDataState( + type = previewExpiredApple, + refreshState = State.Loading, + showProBadge = true, + ) + ), + inSheet = false, + listState = rememberLazyListState(), + sendCommand = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +fun PreviewProSettingsExpiredError( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + ProSettingsHome( + data = ProSettingsViewModel.ProSettingsState( + proDataState = ProDataState( + type = previewExpiredApple, + refreshState = State.Error(Exception()), + showProBadge = true, + ) + ), + inSheet = false, + listState = rememberLazyListState(), sendCommand = {}, onBack = {}, ) @@ -890,11 +1160,36 @@ fun PreviewProSettingsNonPro( PreviewTheme(colors) { ProSettingsHome( data = ProSettingsViewModel.ProSettingsState( - subscriptionState = SubscriptionState( - type = SubscriptionType.NeverSubscribed, + proDataState = ProDataState( + type = ProStatus.NeverSubscribed, + refreshState = State.Success(Unit), + showProBadge = true, + ) + ), + inSheet = false, + listState = rememberLazyListState(), + sendCommand = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +fun PreviewProSettingsNonProInSheet( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + ProSettingsHome( + data = ProSettingsViewModel.ProSettingsState( + proDataState = ProDataState( + type = ProStatus.NeverSubscribed, refreshState = State.Success(Unit), + showProBadge = true, ) ), + inSheet = true, + listState = rememberLazyListState(), sendCommand = {}, onBack = {}, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt index 540656cdd5..9252199ff7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt @@ -1,105 +1,218 @@ package org.thoughtcrime.securesms.preferences.prosettings import android.annotation.SuppressLint +import android.os.Parcelable import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionLayout import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.compose.runtime.remember +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation.NavController import androidx.navigation.compose.NavHost +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController -import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.* +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.CancelSubscription +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.Home +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.PlanConfirmation +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.RefundSubscription +import org.thoughtcrime.securesms.preferences.prosettings.chooseplan.ChoosePlanHomeScreen import org.thoughtcrime.securesms.ui.NavigationAction import org.thoughtcrime.securesms.ui.ObserveAsEvents import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.ui.horizontalSlideComposable // Destinations -sealed interface ProSettingsDestination { +sealed interface ProSettingsDestination: Parcelable { @Serializable + @Parcelize data object Home: ProSettingsDestination @Serializable + @Parcelize data object ChoosePlan: ProSettingsDestination @Serializable + @Parcelize data object PlanConfirmation: ProSettingsDestination @Serializable + @Parcelize data object CancelSubscription: ProSettingsDestination @Serializable + @Parcelize data object RefundSubscription: ProSettingsDestination } +enum class ProNavHostCustomActions { + ON_POST_PLAN_CONFIRMATION, ON_POST_CANCELLATION +} + +private const val KEY_SCROLL_TOP = "scrollToTop" + +@Serializable object ProSettingsGraph + @SuppressLint("RestrictedApi") @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun ProSettingsNavHost( - navigator: UINavigator, + startDestination: ProSettingsDestination = Home, + inSheet: Boolean, onBack: () -> Unit ){ - SharedTransitionLayout { - val navController = rememberNavController() - val scope = rememberCoroutineScope() + val navController = rememberNavController() + val navigator: UINavigator = remember { + UINavigator() + } - // all screens within the Pro Flow can share the same VM - val viewModel = hiltViewModel() + val handleBack: () -> Unit = { + if (navController.previousBackStackEntry != null) { + navController.navigateUp() + } else { + onBack() // Finish activity if at root + } + } - val dialogsState by viewModel.dialogState.collectAsState() - ObserveAsEvents(flow = navigator.navigationActions) { action -> - when (action) { - is NavigationAction.Navigate -> navController.navigate( - action.destination - ) { - action.navOptions(this) - } + ObserveAsEvents(flow = navigator.navigationActions) { action -> + when (action) { + is NavigationAction.Navigate -> navController.navigate( + action.destination + ) { + action.navOptions(this) + } - NavigationAction.NavigateUp -> navController.navigateUp() + NavigationAction.NavigateUp -> handleBack() - is NavigationAction.NavigateToIntent -> { - navController.context.startActivity(action.intent) - } + is NavigationAction.NavigateToIntent -> { + navController.context.startActivity(action.intent) + } - is NavigationAction.ReturnResult -> {} + is NavigationAction.PerformCustomAction -> { + when(action.data as? ProNavHostCustomActions){ + // handle the custom case of dealing with the post "choose plan confirmation"screen + ProNavHostCustomActions.ON_POST_PLAN_CONFIRMATION, + ProNavHostCustomActions.ON_POST_CANCELLATION -> { + // we get here where we either hit back or hit the "ok" button on the plan confirmation screen + // if we are in a sheet we need to close it + if (inSheet) { + onBack() + } // otherwise we should clear the stack and head back to the pro settings home screen + else { + // set a flag to make sure the home screen scroll back to the top + runCatching { + navController.getBackStackEntry(Home) + .savedStateHandle[KEY_SCROLL_TOP] = true + } + + // try to navigate "back" home is possible + val wentBack = navController.popBackStack(route = Home, inclusive = false) + + if (!wentBack) { + // Fallback: if Home wasn't in the back stack + navController.navigate(Home){ + popUpTo(Home){ inclusive = false } + } + } + } + } + + else -> {} + } } + + is NavigationAction.ReturnResult -> {} } + } - NavHost(navController = navController, startDestination = Home) { + NavHost( + navController = navController, + startDestination = ProSettingsGraph + ) { + navigation(startDestination = startDestination) { // Home - horizontalSlideComposable { + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + + // check if we have the scroll flag set + val scrollToTop by entry.savedStateHandle + .getStateFlow(KEY_SCROLL_TOP, false) + .collectAsState() + ProSettingsHomeScreen( viewModel = viewModel, + inSheet = inSheet, + shouldScrollToTop = scrollToTop, + onScrollToTopConsumed = { + // Reset the flag so it doesn't trigger again on rotation + entry.savedStateHandle["scrollToTop"] = false + }, onBack = onBack, ) } // Subscription plan selection - horizontalSlideComposable { - ChoosePlanScreen( + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + ChoosePlanHomeScreen( viewModel = viewModel, - onBack = { scope.launch { navigator.navigateUp() }}, + onBack = handleBack, ) } // Subscription plan confirmation - horizontalSlideComposable { + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) PlanConfirmationScreen( viewModel = viewModel, - onBack = { scope.launch { navigator.navigateUp() }}, + onBack = handleBack, + ) + } + + // Refund + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + RefundPlanScreen( + viewModel = viewModel, + onBack = handleBack, + ) + } + + // Cancellation + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + CancelPlanScreen( + viewModel = viewModel, + onBack = handleBack, ) } } + } - // Dialogs - ProSettingsDialogs( - dialogsState = dialogsState, - sendCommand = viewModel::onCommand, - ) + // Dialogs + // the composable need to wait until the graph has been rendered + val graphReady = remember(navController.currentBackStackEntryAsState().value) { + runCatching { navController.getBackStackEntry(ProSettingsGraph) }.getOrNull() + } + graphReady?.let { entry -> + val vm = navController.proGraphViewModel(entry, navigator) + val dialogsState by vm.dialogState.collectAsState() + ProSettingsDialogs(dialogsState = dialogsState, sendCommand = vm::onCommand) } +} + +@Composable +private fun NavController.proGraphViewModel( + entry: androidx.navigation.NavBackStackEntry, + navigator: UINavigator +): ProSettingsViewModel { + val graphEntry = remember(entry) { getBackStackEntry(ProSettingsGraph) } + return hiltViewModel< + ProSettingsViewModel, + ProSettingsViewModel.Factory + >(graphEntry) { factory -> factory.create(navigator) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index cbbf3cb85d..4a6451be95 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -1,191 +1,311 @@ package org.thoughtcrime.securesms.preferences.prosettings import android.content.Context +import android.content.Intent import android.icu.util.MeasureUnit +import android.widget.Toast +import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.navigation.NavOptionsBuilder import com.squareup.phrase.Phrase +import dagger.Lazy +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.async +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R +import org.session.libsession.database.StorageProtocol +import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.NonTranslatableStringConstants -import org.session.libsession.utilities.StringSubstitutionConstants +import org.session.libsession.utilities.StringSubstitutionConstants.ACTION_TYPE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.CURRENT_PLAN_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.CURRENT_PLAN_LENGTH_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.MONTHLY_PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PERCENT_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_LENGTH_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_LENGTH_SINGULAR_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog -import org.thoughtcrime.securesms.pro.SubscriptionType +import org.thoughtcrime.securesms.pro.ProDataState +import org.thoughtcrime.securesms.pro.ProDetailsRepository +import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.pro.ProStatusManager -import org.thoughtcrime.securesms.pro.SubscriptionState import org.thoughtcrime.securesms.pro.getDefaultSubscriptionStateData +import org.thoughtcrime.securesms.pro.isFromAnotherPlatform import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration import org.thoughtcrime.securesms.pro.subscription.SubscriptionCoordinator +import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager import org.thoughtcrime.securesms.pro.subscription.expiryFromNow import org.thoughtcrime.securesms.ui.SimpleDialogData import org.thoughtcrime.securesms.ui.UINavigator +import org.thoughtcrime.securesms.util.CurrencyFormatter import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.State -import javax.inject.Inject +import java.math.BigDecimal @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) -@HiltViewModel -class ProSettingsViewModel @Inject constructor( - @ApplicationContext private val context: Context, - private val navigator: UINavigator, +@HiltViewModel(assistedFactory = ProSettingsViewModel.Factory::class) +class ProSettingsViewModel @AssistedInject constructor( + @Assisted private val navigator: UINavigator, + @param:ApplicationContext private val context: Context, private val proStatusManager: ProStatusManager, private val subscriptionCoordinator: SubscriptionCoordinator, - private val dateUtils: DateUtils + private val dateUtils: DateUtils, + private val prefs: TextSecurePreferences, + private val proDetailsRepository: ProDetailsRepository, + private val configFactory: Lazy, + private val storage: StorageProtocol, ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(navigator: UINavigator): ProSettingsViewModel + } + private val _proSettingsUIState: MutableStateFlow = MutableStateFlow(ProSettingsState()) val proSettingsUIState: StateFlow = _proSettingsUIState private val _dialogState: MutableStateFlow = MutableStateFlow(DialogsState()) val dialogState: StateFlow = _dialogState - private val _choosePlanState: MutableStateFlow = MutableStateFlow(ChoosePlanState()) - val choosePlanState: StateFlow = _choosePlanState + private val _choosePlanState: MutableStateFlow> = MutableStateFlow(State.Loading) + val choosePlanState: StateFlow> = _choosePlanState + + private val _refundPlanState: MutableStateFlow> = MutableStateFlow(State.Loading) + val refundPlanState: StateFlow> = _refundPlanState + + private val _cancelPlanState: MutableStateFlow> = MutableStateFlow(State.Loading) + val cancelPlanState: StateFlow> = _cancelPlanState init { // observe subscription status viewModelScope.launch { - proStatusManager.subscriptionState.collect { + proStatusManager.proDataState.collect { generateState(it) } } + + // observe purchase events + viewModelScope.launch { + subscriptionCoordinator.getCurrentManager().purchaseEvents.collect { purchaseEvent -> + val data = choosePlanState.value + + // stop loader + if(data is State.Success) { + _choosePlanState.update { + State.Success( + data.value.copy(purchaseInProgress = false) + ) + } + } + + when(purchaseEvent){ + is SubscriptionManager.PurchaseEvent.Success -> { + navigator.navigate(destination = ProSettingsDestination.PlanConfirmation) + } + + is SubscriptionManager.PurchaseEvent.Failed.GenericError -> { + Toast.makeText( + context, + purchaseEvent.errorMessage ?: context.getString(R.string.errorGeneric), + Toast.LENGTH_SHORT + ).show() + } + + is SubscriptionManager.PurchaseEvent.Failed.ServerError -> { + // this is a special case of failure. We should display a custom dialog and allow the user to retry + _dialogState.update { + val action = context.getString( + when(_proSettingsUIState.value.proDataState.type) { + is ProStatus.Active -> R.string.proUpdatingAction + is ProStatus.Expired -> R.string.proRenewingAction + else -> R.string.proUpgradingAction + } + ) + + it.copy( + showSimpleDialog = SimpleDialogData( + title = context.getString(R.string.paymentError), + message = Phrase.from(context, R.string.paymentProError) + .put(ACTION_TYPE_KEY, action) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format(), + positiveText = context.getString(R.string.retry), + negativeText = context.getString(R.string.helpSupport), + positiveStyleDanger = false, + showXIcon = true, + onPositive = { + // show the loader again + val data = choosePlanState.value + if(data is State.Success) { + _choosePlanState.update { + State.Success( + data.value.copy(purchaseInProgress = true) + ) + } + } + + // retry the post purchase code + subscriptionCoordinator.getCurrentManager().onPurchaseSuccessful( + orderId = purchaseEvent.orderId, + paymentId = purchaseEvent.paymentId + ) + }, + onNegative = { + onCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) + } + ) + ) + } + } + + is SubscriptionManager.PurchaseEvent.Cancelled -> { + // nothing to do in this case + } + } + } + } } - private fun generateState(subscriptionState: SubscriptionState){ - //todo PRO need to properly calculate this + private fun generateState(proDataState: ProDataState){ + val subType = proDataState.type - val subType = subscriptionState.type + // calculate stats for pro users + if(subType is ProStatus.Active) refreshProStats() _proSettingsUIState.update { - ProSettingsState( - subscriptionState = subscriptionState, + it.copy( + proDataState = proDataState, subscriptionExpiryLabel = when(subType){ - is SubscriptionType.Active.AutoRenewing -> + is ProStatus.Active.AutoRenewing -> Phrase.from(context, R.string.proAutoRenewTime) .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(TIME_KEY, dateUtils.getExpiryString(subType.proStatus.validUntil)) + .put(TIME_KEY, dateUtils.getExpiryString(subType.validUntil)) .format() - is SubscriptionType.Active.Expiring -> + is ProStatus.Active.Expiring -> Phrase.from(context, R.string.proExpiringTime) .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(TIME_KEY, dateUtils.getExpiryString(subType.proStatus.validUntil)) + .put(TIME_KEY, dateUtils.getExpiryString(subType.validUntil)) .format() else -> "" }, subscriptionExpiryDate = when(subType){ - is SubscriptionType.Active -> subType.duration.expiryFromNow() + is ProStatus.Active -> subType.duration.expiryFromNow() else -> "" - } + }, ) } + } - _choosePlanState.update { - val isActive = subType is SubscriptionType.Active - val currentPlan12Months = isActive && subType.duration == ProSubscriptionDuration.TWELVE_MONTHS - val currentPlan3Months = isActive && subType.duration == ProSubscriptionDuration.THREE_MONTHS - val currentPlan1Month = isActive && subType.duration == ProSubscriptionDuration.ONE_MONTH - - ChoosePlanState( - subscriptionType = subType, - enableButton = subType !is SubscriptionType.Active.AutoRenewing, // only the auto-renew can have a disabled state - plans = listOf( - ProPlan( - title = Phrase.from(context.getText(R.string.proPriceTwelveMonths)) - .put(MONTHLY_PRICE_KEY, "$3.99") //todo PRO calculate properly - .format().toString(), - subtitle = Phrase.from(context.getText(R.string.proBilledAnnually)) - .put(PRICE_KEY, "$47.99") //todo PRO calculate properly - .format().toString(), - selected = currentPlan12Months, - currentPlan = currentPlan12Months, - durationType = ProSubscriptionDuration.TWELVE_MONTHS, - badges = buildList { - if(currentPlan12Months){ - add( - ProPlanBadge(context.getString(R.string.currentPlan)) - ) - } + fun ensureChoosePlanState(){ + // Get the choose plan state ready in loading mode + _choosePlanState.update { State.Loading } - add( - ProPlanBadge( - "33% Off", //todo PRO calculate properly - if(currentPlan12Months) Phrase.from(context.getText(R.string.proDiscountTooltip)) - .put(PERCENT_KEY, "33") //todo PRO calculate properly - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format().toString() - else null - ) - ) - }, - ), - ProPlan( - title = Phrase.from(context.getText(R.string.proPriceThreeMonths)) - .put(MONTHLY_PRICE_KEY, "$4.99") //todo PRO calculate properly - .format().toString(), - subtitle = Phrase.from(context.getText(R.string.proBilledQuarterly)) - .put(PRICE_KEY, "$14.99") //todo PRO calculate properly - .format().toString(), - selected = currentPlan3Months, - currentPlan = currentPlan3Months, - durationType = ProSubscriptionDuration.THREE_MONTHS, - badges = buildList { - if(currentPlan3Months){ - add( - ProPlanBadge(context.getString(R.string.currentPlan)) - ) - } + // while the user is on the page we need to calculate the "choose plan" data + viewModelScope.launch { + val subType = _proSettingsUIState.value.proDataState.type + + // first check if the user has a valid subscription and billing + val hasBillingCapacity = subscriptionCoordinator.getCurrentManager().supportsBilling.value + val hasValidSub = subscriptionCoordinator.getCurrentManager().hasValidSubscription() + + // next get the plans, including their pricing, unless there is no billing + // or the user is pro without a valid subscription + // or the user is pro but non originating + val noPriceNeeded = !hasBillingCapacity + || (subType is ProStatus.Active && !hasValidSub) + || (subType is ProStatus.Active && subType.providerData.isFromAnotherPlatform()) + + val plans = if(noPriceNeeded) emptyList() + else { + // attempt to get the prices from the subscription provider + // return early in case of error + try { + getSubscriptionPlans(subType) + } catch (e: Exception){ + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "Error while trying to get subscription plans", e) + _choosePlanState.update { State.Error(e) } + return@launch + } + } - add( - ProPlanBadge( - "16% Off", //todo PRO calculate properly - if(currentPlan3Months) Phrase.from(context.getText(R.string.proDiscountTooltip)) - .put(PERCENT_KEY, "16") //todo PRO calculate properly - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format().toString() - else null - ) - ) - }, - ), - ProPlan( - title = Phrase.from(context.getText(R.string.proPriceOneMonth)) - .put(MONTHLY_PRICE_KEY, "$5.99") //todo PRO calculate properly - .format().toString(), - subtitle = Phrase.from(context.getText(R.string.proBilledMonthly)) - .put(PRICE_KEY, "$5") //todo PRO calculate properly - .format().toString(), - selected = currentPlan1Month, - currentPlan = currentPlan1Month, - durationType = ProSubscriptionDuration.ONE_MONTH, - badges = if(currentPlan1Month) listOf( - ProPlanBadge(context.getString(R.string.currentPlan)) - ) else emptyList(), - ), + _choosePlanState.update { + State.Success( + ChoosePlanState( + proStatus = subType, + hasValidSubscription = hasValidSub, + hasBillingCapacity = hasBillingCapacity, + enableButton = subType !is ProStatus.Active.AutoRenewing, // only the auto-renew can have a disabled state + plans = plans + ) ) - ) + } + } + } + + fun ensureCancelState(){ + val sub = _proSettingsUIState.value.proDataState.type + if(sub !is ProStatus.Active) return + + _cancelPlanState.update { State.Loading } + viewModelScope.launch { + _cancelPlanState.update { State.Loading } + val hasValidSubscription = subscriptionCoordinator.getCurrentManager().hasValidSubscription() + + _cancelPlanState.update { + State.Success( + CancelPlanState( + proStatus = sub, + hasValidSubscription = hasValidSubscription + ) + ) + } } } + fun ensureRefundState(){ + val sub = _proSettingsUIState.value.proDataState.type + if(sub !is ProStatus.Active) return + + _refundPlanState.update { State.Loading } + + viewModelScope.launch { + _refundPlanState.update { + val isQuickRefund = if(prefs.getDebugIsWithinQuickRefund() && prefs.forceCurrentUserAsPro()) true // debug mode + else sub.isWithinQuickRefundWindow() + + State.Success( + RefundPlanState( + proStatus = sub, + isQuickRefund = isQuickRefund, + quickRefundUrl = sub.providerData.refundPlatformUrl + ) + ) + } + } + } fun onCommand(command: Commands) { when (command) { @@ -195,19 +315,38 @@ class ProSettingsViewModel @Inject constructor( } } - Commands.ShowPlanUpdate -> { - when(_proSettingsUIState.value.subscriptionState.refreshState){ + is Commands.GoToChoosePlan -> { + when(_proSettingsUIState.value.proDataState.refreshState){ // if we are in a loading or refresh state we should show a dialog instead is State.Loading -> { + val state = _proSettingsUIState.value.proDataState.type + val (title, message) = when{ + state is ProStatus.Active -> Phrase.from(context.getText(R.string.proAccessLoading)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.proAccessLoadingDescription)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + state is ProStatus.NeverSubscribed + || command.inSheet -> Phrase.from(context.getText(R.string.checkingProStatus)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.checkingProStatusContinue)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + else -> Phrase.from(context.getText(R.string.checkingProStatus)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.checkingProStatusRenew)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + } + _dialogState.update { it.copy( showSimpleDialog = SimpleDialogData( - title = Phrase.from(context.getText(R.string.proPlanLoading)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString(), - message = Phrase.from(context.getText(R.string.proPlanLoadingDescription)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format(), + title = title, + message = message, positiveText = context.getString(R.string.okay), positiveStyleDanger = false, ) @@ -216,20 +355,41 @@ class ProSettingsViewModel @Inject constructor( } is State.Error -> { + val state = _proSettingsUIState.value.proDataState.type + val (title, message) = when{ + state is ProStatus.Active -> Phrase.from(context.getText(R.string.proAccessError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.proAccessNetworkLoadError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .format() + state is ProStatus.NeverSubscribed + || command.inSheet-> Phrase.from(context.getText(R.string.proStatusError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.proStatusNetworkErrorContinue)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + else -> Phrase.from(context.getText(R.string.proStatusError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.proStatusRenewError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .format() + } + _dialogState.update { it.copy( showSimpleDialog = SimpleDialogData( - title = Phrase.from(context.getText(R.string.proPlanError)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString(), - message = Phrase.from(context.getText(R.string.proPlanNetworkLoadError)) - .put(APP_NAME_KEY, context.getString(R.string.app_name)) - .format(), + title = title, + message = message, positiveText = context.getString(R.string.retry), negativeText = context.getString(R.string.helpSupport), positiveStyleDanger = false, showXIcon = true, - onPositive = { refreshSubscriptionData() }, + onPositive = { refreshProDetails(true) }, onNegative = { onCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) } @@ -238,23 +398,76 @@ class ProSettingsViewModel @Inject constructor( } } - // otherwise navigate to the "Choose plan" screen - else -> navigateTo(ProSettingsDestination.ChoosePlan) + // go to the "choose plan" screen + else -> goToChoosePlan() + } + } + + Commands.GoToRefund -> { + val sub = _proSettingsUIState.value.proDataState.type + if(sub !is ProStatus.Active) return + + navigateTo(ProSettingsDestination.RefundSubscription) + } + + Commands.GoToCancel -> { + val sub = _proSettingsUIState.value.proDataState.type + if(sub !is ProStatus.Active) return + + navigateTo(ProSettingsDestination.CancelSubscription) + } + + Commands.OnPostPlanConfirmation -> { + // send a custom action to deal with "post plan confirmation" + viewModelScope.launch { + navigator.sendCustomAction(ProNavHostCustomActions.ON_POST_PLAN_CONFIRMATION) + } + } + + Commands.OpenCancelSubscriptionPage -> { + val subUrl = (_proSettingsUIState.value.proDataState.type as? ProStatus.Active) + ?.providerData?.cancelSubscriptionUrl + if(!subUrl.isNullOrEmpty()){ + viewModelScope.launch { + navigator.navigateToIntent( + Intent(Intent.ACTION_VIEW, subUrl.toUri()) + ) + } } } is Commands.SetShowProBadge -> { - //todo PRO implement + configFactory.get().withMutableUserConfigs { configs -> + configs.userProfile.setProBadge(command.show) + } + } + + is Commands.RefeshProDetails -> { + refreshProDetails(true) + } + + is Commands.OnUserBackFromCancellation -> { + // refresh details + refreshProDetails(true) + + // send action to handle post cancellation to the navigator + viewModelScope.launch { + navigator.sendCustomAction(ProNavHostCustomActions.ON_POST_CANCELLATION) + } } is Commands.SelectProPlan -> { - _choosePlanState.update { data -> - data.copy( - plans = data.plans.map { - it.copy(selected = it == command.plan) - }, - enableButton = data.subscriptionType !is SubscriptionType.Active.AutoRenewing - || !command.plan.currentPlan + val data: ChoosePlanState = (_choosePlanState.value as? State.Success)?.value ?: return + + _choosePlanState.update { + State.Success( + data.copy( + plans = data.plans.map { + it.copy(selected = it == command.plan) + }, + enableButton = data.proStatus !is ProStatus.Active.AutoRenewing + || !command.plan.currentPlan + ) ) } } @@ -272,11 +485,11 @@ class ProSettingsViewModel @Inject constructor( } Commands.GetProPlan -> { - val currentSubscription = _proSettingsUIState.value.subscriptionState.type - + val currentSubscription = _proSettingsUIState.value.proDataState.type + val selectedPlan = getSelectedPlan() ?: return - if(currentSubscription is SubscriptionType.Active){ - val newSubscriptionExpiryString = getSelectedPlan().durationType.expiryFromNow() + if(currentSubscription is ProStatus.Active){ + val newSubscriptionExpiryString = selectedPlan.durationType.expiryFromNow() val currentSubscriptionDuration = DateUtils.getLocalisedTimeDuration( context = context, @@ -286,28 +499,31 @@ class ProSettingsViewModel @Inject constructor( val selectedSubscriptionDuration = DateUtils.getLocalisedTimeDuration( context = context, - amount = getSelectedPlan().durationType.duration.months, + amount = selectedPlan.durationType.duration.months, unit = MeasureUnit.MONTH ) _dialogState.update { it.copy( showSimpleDialog = SimpleDialogData( - title = context.getString(R.string.updatePlan), - message = if(currentSubscription is SubscriptionType.Active.AutoRenewing) - Phrase.from(context.getText(R.string.proUpdatePlanDescription)) - .put(CURRENT_PLAN_KEY, currentSubscriptionDuration) - .put(SELECTED_PLAN_KEY, selectedSubscriptionDuration) - .put(DATE_KEY, newSubscriptionExpiryString) - .put(SELECTED_PLAN_KEY, selectedSubscriptionDuration) + title = Phrase.from(context, R.string.updateAccess) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), + message = if(currentSubscription is ProStatus.Active.AutoRenewing) + Phrase.from(context.getText(R.string.proUpdateAccessDescription)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(DATE_KEY, newSubscriptionExpiryString) + .put(CURRENT_PLAN_LENGTH_KEY, currentSubscriptionDuration) + .put(SELECTED_PLAN_LENGTH_KEY, selectedSubscriptionDuration) + // for this string below, we want to remove the 's' at the end if there is one: 12 Months becomes 12 Month + .put(SELECTED_PLAN_LENGTH_SINGULAR_KEY, selectedSubscriptionDuration.removeSuffix("s")) .format() - else Phrase.from(context.getText(R.string.proUpdatePlanExpireDescription)) + else Phrase.from(context.getText(R.string.proUpdateAccessExpireDescription)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(DATE_KEY, newSubscriptionExpiryString) - .put(DATE_KEY, newSubscriptionExpiryString) - .put(SELECTED_PLAN_KEY, selectedSubscriptionDuration) + .put(SELECTED_PLAN_LENGTH_KEY, selectedSubscriptionDuration) .format(), - positiveText = context.getString(R.string.updatePlan), + positiveText = context.getString(R.string.update), negativeText = context.getString(R.string.cancel), positiveStyleDanger = false, onPositive = { getPlanFromProvider() }, @@ -332,19 +548,37 @@ class ProSettingsViewModel @Inject constructor( } } - Commands.OnHeaderClicked -> { - when(_proSettingsUIState.value.subscriptionState.refreshState){ + is Commands.OnHeaderClicked -> { + when(_proSettingsUIState.value.proDataState.refreshState){ // if we are in a loading or refresh state we should show a dialog instead is State.Loading -> { + val state = _proSettingsUIState.value.proDataState.type + val (title, message) = when{ + state is ProStatus.Active -> Phrase.from(context.getText(R.string.proStatusLoading)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.proStatusLoadingDescription)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + state is ProStatus.NeverSubscribed + || command.inSheet-> Phrase.from(context.getText(R.string.checkingProStatus)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.checkingProStatusContinue)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + else -> Phrase.from(context.getText(R.string.checkingProStatus)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.checkingProStatusDescription)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + } _dialogState.update { it.copy( showSimpleDialog = SimpleDialogData( - title = Phrase.from(context.getText(R.string.proStatusLoading)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString(), - message = Phrase.from(context.getText(R.string.proStatusLoadingDescription)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format(), + title = title, + message = message, positiveText = context.getString(R.string.okay), positiveStyleDanger = false, ) @@ -354,19 +588,38 @@ class ProSettingsViewModel @Inject constructor( is State.Error -> { _dialogState.update { + val state = _proSettingsUIState.value.proDataState.type + val (title, message) = when{ + state is ProStatus.Active -> Phrase.from(context.getText(R.string.proStatusError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.proStatusRefreshNetworkError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + state is ProStatus.NeverSubscribed || + command.inSheet -> Phrase.from(context.getText(R.string.proStatusError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.proStatusNetworkErrorContinue)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + else -> Phrase.from(context.getText(R.string.proStatusError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.proStatusRefreshNetworkError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + } + it.copy( showSimpleDialog = SimpleDialogData( - title = Phrase.from(context.getText(R.string.proStatusError)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString(), - message = Phrase.from(context.getText(R.string.proStatusRefreshNetworkError)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format(), + title = title, + message = message, positiveText = context.getString(R.string.retry), negativeText = context.getString(R.string.helpSupport), positiveStyleDanger = false, showXIcon = true, - onPositive = { refreshSubscriptionData() }, + onPositive = { refreshProDetails(true) }, onNegative = { onCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) } @@ -378,26 +631,231 @@ class ProSettingsViewModel @Inject constructor( else -> {} } } + + Commands.OnProStatsClicked -> { + when(_proSettingsUIState.value.proStats){ + // if we are in a loading or refresh state we should show a dialog instead + is State.Loading -> { + _dialogState.update { + it.copy( + showSimpleDialog = SimpleDialogData( + title = Phrase.from(context.getText(R.string.proStatsLoading)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), + message = Phrase.from(context.getText(R.string.proStatsLoadingDescription)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format(), + positiveText = context.getString(R.string.okay), + positiveStyleDanger = false, + ) + ) + } + } + + else -> {} + } + } } } - private fun refreshSubscriptionData(){ - //todo PRO implement properly + private fun refreshProDetails(force: Boolean){ + // stop early if we are already refreshing + if(_proSettingsUIState.value.proDataState.refreshState is State.Loading) return + + // refreshes the pro details data + proDetailsRepository.requestRefresh(force = force) } - private fun getSelectedPlan(): ProPlan { - return _choosePlanState.value.plans.first { it.selected } + private fun getSelectedPlan(): ProPlan? { + return (_choosePlanState.value as? State.Success)?.value?.plans?.first { it.selected } } - private fun getPlanFromProvider(){ - subscriptionCoordinator.getCurrentManager().purchasePlan( - getSelectedPlan().durationType + private fun goToChoosePlan(){ + // Navigate to choose plan screen + navigateTo(ProSettingsDestination.ChoosePlan) + } + + private suspend fun getSubscriptionPlans(subType: ProStatus): List { + val isActive = subType is ProStatus.Active + val currentPlan12Months = isActive && subType.duration == ProSubscriptionDuration.TWELVE_MONTHS + val currentPlan3Months = isActive && subType.duration == ProSubscriptionDuration.THREE_MONTHS + val currentPlan1Month = isActive && subType.duration == ProSubscriptionDuration.ONE_MONTH + + // get prices from the subscription provider + val prices = subscriptionCoordinator.getCurrentManager().getSubscriptionPrices() + + val data1Month = calculatePricesFor(prices.firstOrNull{ it.subscriptionDuration == ProSubscriptionDuration.ONE_MONTH }) + val data3Month = calculatePricesFor(prices.firstOrNull{ it.subscriptionDuration == ProSubscriptionDuration.THREE_MONTHS }) + val data12Month = calculatePricesFor(prices.firstOrNull{ it.subscriptionDuration == ProSubscriptionDuration.TWELVE_MONTHS }) + + val baseline = data1Month?.perMonthUnits ?: BigDecimal.ZERO + + val plan12Months = data12Month?.let { + ProPlan( + title = Phrase.from(context.getText(R.string.proPriceTwelveMonths)) + .put(MONTHLY_PRICE_KEY, it.perMonthText) + .format().toString(), + subtitle = Phrase.from(context.getText(R.string.proBilledAnnually)) + .put(PRICE_KEY, it.totalText) + .format().toString(), + selected = currentPlan12Months || subType !is ProStatus.Active, // selected if our active sub is 12 month, or as a default for non pro or renew + currentPlan = currentPlan12Months, + durationType = ProSubscriptionDuration.TWELVE_MONTHS, + badges = buildList { + if (currentPlan12Months) add(ProPlanBadge(context.getString(R.string.currentBilling))) + discountBadge(baseline = baseline, it.perMonthUnits, showTooltip = currentPlan12Months)?.let(this::add) + } + ) + } + + val plan3Months = data3Month?.let { + ProPlan( + title = Phrase.from(context.getText(R.string.proPriceThreeMonths)) + .put(MONTHLY_PRICE_KEY, it.perMonthText) + .format().toString(), + subtitle = Phrase.from(context.getText(R.string.proBilledQuarterly)) + .put(PRICE_KEY, it.totalText) + .format().toString(), + selected = currentPlan3Months, + currentPlan = currentPlan3Months, + durationType = ProSubscriptionDuration.THREE_MONTHS, + badges = buildList { + if (currentPlan3Months) add(ProPlanBadge(context.getString(R.string.currentBilling))) + discountBadge(baseline = baseline, it.perMonthUnits, showTooltip = currentPlan3Months)?.let(this::add) + } + ) + } + + val plan1Month = data1Month?.let { + ProPlan( + title = Phrase.from(context.getText(R.string.proPriceOneMonth)) + .put(MONTHLY_PRICE_KEY, it.perMonthText) + .format().toString(), + subtitle = Phrase.from(context.getText(R.string.proBilledMonthly)) + .put(PRICE_KEY, it.totalText) + .format().toString(), + selected = currentPlan1Month, + currentPlan = currentPlan1Month, + durationType = ProSubscriptionDuration.ONE_MONTH, + badges = if (currentPlan1Month) listOf(ProPlanBadge(context.getString(R.string.currentBilling))) else emptyList() + // no discount on the baseline 1 month... + ) + } + + return listOfNotNull(plan12Months, plan3Months, plan1Month) + } + + private data class PriceDisplayData(val perMonthUnits: BigDecimal, val perMonthText: String, val totalText: String) + + private fun calculatePricesFor(pricing: SubscriptionManager.SubscriptionPricing?): PriceDisplayData? { + if(pricing == null) return null + + val months = CurrencyFormatter.monthsFromIso(pricing.billingPeriodIso) + val perMonthUnits = CurrencyFormatter.perMonthUnitsFloor(pricing.priceAmountMicros, months, pricing.priceCurrencyCode) + val perMonthText = CurrencyFormatter.formatUnits(perMonthUnits, pricing.priceCurrencyCode) + + val totalUnits = CurrencyFormatter.microToBigDecimal(pricing.priceAmountMicros) + val totalText = CurrencyFormatter.formatUnits( + amountUnits = totalUnits, + currencyCode = pricing.priceCurrencyCode + ) + + return PriceDisplayData(perMonthUnits, perMonthText, totalText) + } + + private fun discountBadge(baseline: BigDecimal ,perMonthUnits: BigDecimal, showTooltip: Boolean): ProPlanBadge? { + val pct = CurrencyFormatter.percentOffFloor(baseline, perMonthUnits) + if (pct <= 0) return null + val tooltip = if (showTooltip) + Phrase.from(context.getText(R.string.proDiscountTooltip)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(PERCENT_KEY, pct.toString()) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format().toString() + else null + return ProPlanBadge( + title = Phrase.from(context.getText(R.string.proPercentOff)) + .put(PERCENT_KEY, pct.toString()) + .format().toString(), + tooltip = tooltip ) } - private fun navigateTo(destination: ProSettingsDestination){ + private fun getPlanFromProvider(){ viewModelScope.launch { - navigator.navigate(destination) + val selectedPlan = getSelectedPlan() ?: return@launch + + // let the provider handle the plan from their UI + val providerResult = subscriptionCoordinator.getCurrentManager().purchasePlan( + selectedPlan.durationType + ) + + // check if we managed to display the plan from the provider + val data = choosePlanState.value + if(providerResult.isSuccess && data is State.Success) { + // show a loader while the user is looking at the UI from the provider + _choosePlanState.update { + State.Success( + data.value.copy(purchaseInProgress = true) + ) + } + } + } + } + + private fun navigateTo( + destination: ProSettingsDestination, + navOptions: NavOptionsBuilder.() -> Unit = {} + ){ + viewModelScope.launch { + navigator.navigate(destination, navOptions) + } + } + + private fun refreshProStats(){ + viewModelScope.launch { + // show a loader for the stats + _proSettingsUIState.update { + it.copy( + proStats = State.Loading + ) + } + + // calculate pro stats values + try { + val stats = withContext(Dispatchers.IO) { + val pinsDeferred = async { + storage.getTotalPinned() + } + + val badgesDeferred = async { + storage.getTotalSentProBadges() + } + + val longMsgDeferred = async { + storage.getTotalSentLongMessages() + } + + ProStats( + groupsUpdated = 0, + pinnedConversations = pinsDeferred.await(), + proBadges = badgesDeferred.await(), + longMessages = longMsgDeferred.await(), + ) + } + + // update ui with results + _proSettingsUIState.update { + it.copy(proStats = State.Success(stats)) + } + } catch (e: Exception) { + // currently the UI doesn't have an error display + // it will look like it's still loading + // but the logic is there in case we have a look for stats errors + _proSettingsUIState.update { + it.copy(proStats = State.Error(e)) + } + } } } @@ -407,29 +865,53 @@ class ProSettingsViewModel @Inject constructor( data object HideTCPolicyDialog: Commands data object HideSimpleDialog : Commands - object ShowPlanUpdate: Commands + data class GoToChoosePlan(val inSheet: Boolean): Commands + object GoToRefund: Commands + object GoToCancel: Commands + object OnPostPlanConfirmation: Commands + + object OpenCancelSubscriptionPage: Commands + object OnUserBackFromCancellation: Commands + data class SetShowProBadge(val show: Boolean): Commands data class SelectProPlan(val plan: ProPlan): Commands data object GetProPlan: Commands data object ConfirmProPlan: Commands - data object OnHeaderClicked: Commands + data class OnHeaderClicked(val inSheet: Boolean): Commands + data object OnProStatsClicked: Commands + + data object RefeshProDetails: Commands } data class ProSettingsState( - val subscriptionState: SubscriptionState = getDefaultSubscriptionStateData(), - val proStats: ProStats = ProStats(), + val proDataState: ProDataState = getDefaultSubscriptionStateData(), + val proStats: State = State.Loading, val subscriptionExpiryLabel: CharSequence = "", // eg: "Pro auto renewing in 3 days" - val subscriptionExpiryDate: CharSequence = "" // eg: "May 21st, 2025" + val subscriptionExpiryDate: CharSequence = "", // eg: "May 21st, 2025" ) data class ChoosePlanState( - val subscriptionType: SubscriptionType = SubscriptionType.NeverSubscribed, + val proStatus: ProStatus = ProStatus.NeverSubscribed, + val hasBillingCapacity: Boolean = false, + val hasValidSubscription: Boolean = false, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession + val purchaseInProgress: Boolean = false, val plans: List = emptyList(), val enableButton: Boolean = false, ) + data class CancelPlanState( + val proStatus: ProStatus.Active, + val hasValidSubscription: Boolean, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession + ) + + data class RefundPlanState( + val proStatus: ProStatus.Active, + val isQuickRefund: Boolean, + val quickRefundUrl: String? + ) + data class ProStats( val groupsUpdated: Int = 0, val pinnedConversations: Int = 0, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt new file mode 100644 index 0000000000..d2793ee74e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt @@ -0,0 +1,97 @@ +package org.thoughtcrime.securesms.preferences.prosettings + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.DEVICE_TYPE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACCOUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog +import org.thoughtcrime.securesms.pro.ProStatus +import org.thoughtcrime.securesms.pro.previewAutoRenewingApple +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) +@Composable +fun RefundPlanNonOriginating( + subscription: ProStatus.Active, + sendCommand: (ProSettingsViewModel.Commands) -> Unit, + onBack: () -> Unit, +){ + val context = LocalContext.current + + BaseNonOriginatingProSettingsScreen( + disabled = true, + onBack = onBack, + headerTitle = stringResource(R.string.proRefundDescription), + buttonText = Phrase.from(context.getText(R.string.openPlatformWebsite)) + .put(PLATFORM_KEY, subscription.providerData.platform) + .format().toString(), + dangerButton = true, + onButtonClick = { + sendCommand(ShowOpenUrlDialog(subscription.providerData.refundSupportUrl)) + }, + contentTitle = Phrase.from(context.getText(R.string.proRefunding)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), + contentDescription = Phrase.from(context.getText(R.string.proPlanPlatformRefund)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PLATFORM_STORE_KEY, subscription.providerData.store) + .put(PLATFORM_ACCOUNT_KEY, subscription.providerData.platformAccount) + .format(), + linkCellsInfo = stringResource(R.string.refundRequestOptions), + linkCells = listOf( + NonOriginatingLinkCellData( + title = Phrase.from(context.getText(R.string.onDevice)) + .put(DEVICE_TYPE_KEY, subscription.providerData.device) + .format(), + info = Phrase.from(context.getText(R.string.proRefundAccountDevice)) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .put(DEVICE_TYPE_KEY, subscription.providerData.device) + .put(PLATFORM_ACCOUNT_KEY, subscription.providerData.platformAccount) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format(), + iconRes = R.drawable.ic_smartphone + ), + NonOriginatingLinkCellData( + title = Phrase.from(context.getText(R.string.onPlatformWebsite)) + .put(PLATFORM_KEY, subscription.providerData.platform) + .format(), + info = Phrase.from(context.getText(R.string.requestRefundPlatformWebsite)) + .put(PLATFORM_KEY, subscription.providerData.platform) + .put(PLATFORM_ACCOUNT_KEY, subscription.providerData.platformAccount) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format(), + iconRes = R.drawable.ic_globe + ) + ) + ) +} + +@Preview +@Composable +private fun PreviewUpdatePlan( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + val context = LocalContext.current + RefundPlanNonOriginating ( + subscription = previewAutoRenewingApple, + sendCommand = {}, + onBack = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt new file mode 100644 index 0000000000..4e8bc5f36a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt @@ -0,0 +1,191 @@ +package org.thoughtcrime.securesms.preferences.prosettings + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog +import org.thoughtcrime.securesms.pro.ProStatus +import org.thoughtcrime.securesms.pro.isFromAnotherPlatform +import org.thoughtcrime.securesms.pro.previewAutoRenewingApple +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.bold + + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun RefundPlanScreen( + viewModel: ProSettingsViewModel, + onBack: () -> Unit, +) { + LaunchedEffect(Unit) { + // ensuring we get the latest data here + // since we can deep link to this screen without going through the pro home screen + viewModel.ensureRefundState() + } + + val state by viewModel.refundPlanState.collectAsState() + + BaseStateProScreen( + state = state, + onBack = onBack + ) { refundData -> + val activePlan = refundData.proStatus + + // there are different UI depending on the state + when { + // there is an active subscription but from a different platform + activePlan.providerData.isFromAnotherPlatform() -> + RefundPlanNonOriginating( + subscription = activePlan, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + + // default refund screen + else -> RefundPlan( + data = activePlan, + isQuickRefund = refundData.isQuickRefund, + quickRefundUrl = refundData.quickRefundUrl, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) +@Composable +fun RefundPlan( + data: ProStatus.Active, + isQuickRefund: Boolean, + quickRefundUrl: String?, + sendCommand: (ProSettingsViewModel.Commands) -> Unit, + onBack: () -> Unit, +) { + val context = LocalContext.current + + BaseCellButtonProSettingsScreen( + disabled = true, + onBack = onBack, + buttonText = if(isQuickRefund) Phrase.from(context.getText(R.string.openPlatformWebsite)) + .put(PLATFORM_KEY, data.providerData.platform) + .format().toString() + else stringResource(R.string.requestRefund), + dangerButton = true, + onButtonClick = { + if(isQuickRefund && !quickRefundUrl.isNullOrEmpty()){ + sendCommand(ShowOpenUrlDialog(quickRefundUrl)) + } else { + sendCommand(ShowOpenUrlDialog(data.providerData.refundSupportUrl)) + } + }, + title = stringResource(R.string.proRefundDescription), + ){ + Column { + Text( + text = Phrase.from(context.getText(R.string.proRefunding)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), + style = LocalType.current.base.bold(), + color = LocalColors.current.text, + ) + + Spacer(Modifier.height(LocalDimensions.current.xxxsSpacing)) + + Text( + text = annotatedStringResource( + if(isQuickRefund) + Phrase.from(context.getText(R.string.proRefundRequestStorePolicies)) + .put(PLATFORM_KEY, data.providerData.platform) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .format() + else Phrase.from(context.getText(R.string.proRefundRequestSessionSupport)) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .format() + ), + style = LocalType.current.base, + color = LocalColors.current.text, + ) + + Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) + + Text( + text = stringResource(R.string.important), + style = LocalType.current.base.bold(), + color = LocalColors.current.text, + ) + + Spacer(Modifier.height(LocalDimensions.current.xxxsSpacing)) + + Text( + text = annotatedStringResource( + Phrase.from(context.getText(R.string.proImportantDescription)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + ), + style = LocalType.current.base, + color = LocalColors.current.text, + ) + } + } +} + +@Preview +@Composable +private fun PreviewRefundPlan( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + RefundPlan( + data = previewAutoRenewingApple, + isQuickRefund = false, + quickRefundUrl = "", + sendCommand = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewQuickRefundPlan( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + RefundPlan( + data = previewAutoRenewingApple, + isQuickRefund = true, + quickRefundUrl = "", + sendCommand = {}, + onBack = {}, + ) + } +} + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt similarity index 72% rename from app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt rename to app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt index 7660028ceb..da75c980b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.preferences.prosettings +package org.thoughtcrime.securesms.preferences.prosettings.chooseplan import android.icu.util.MeasureUnit import androidx.compose.animation.ExperimentalSharedTransitionApi @@ -6,12 +6,23 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement +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.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -25,6 +36,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -36,19 +48,28 @@ import com.squareup.phrase.Phrase import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.StringSubstitutionConstants.ACTION_TYPE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.ACTIVATION_TYPE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.CURRENT_PLAN_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.CURRENT_PLAN_LENGTH_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.ENTITY_KEY import org.session.libsession.utilities.StringSubstitutionConstants.ICON_KEY import org.session.libsession.utilities.StringSubstitutionConstants.MONTHLY_PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY +import org.thoughtcrime.securesms.preferences.prosettings.BaseProSettingsScreen +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.GetProPlan +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.SelectProPlan +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowTCPolicyDialog import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.ProPlan import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.ProPlanBadge -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.* -import org.thoughtcrime.securesms.pro.SubscriptionType +import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration import org.thoughtcrime.securesms.pro.subscription.expiryFromNow +import org.thoughtcrime.securesms.ui.LoadingArcOr import org.thoughtcrime.securesms.ui.SpeechBubbleTooltip import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.RadioButtonIndicator @@ -67,35 +88,6 @@ import org.thoughtcrime.securesms.ui.theme.bold import org.thoughtcrime.securesms.util.DateUtils -@OptIn(ExperimentalSharedTransitionApi::class) -@Composable -fun ChoosePlanScreen( - viewModel: ProSettingsViewModel, - onBack: () -> Unit, -) { - val planData by viewModel.choosePlanState.collectAsState() - - // there are different UI depending on the state - when { - // there is an active subscription but from a different platform - (planData.subscriptionType as? SubscriptionType.Active)?.nonOriginatingSubscription != null -> - ChoosePlanNonOriginating( - subscription = planData.subscriptionType as SubscriptionType.Active, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - - //todo PRO handle the case here when there are no SubscriptionManager available (for example fdroid builds) - - // default plan chooser - else -> ChoosePlan( - planData = planData, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - } -} - @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable fun ChoosePlan( @@ -115,30 +107,28 @@ fun ChoosePlan( Spacer(Modifier.height(LocalDimensions.current.spacing)) val context = LocalContext.current - val title = when(planData.subscriptionType) { - is SubscriptionType.Expired -> - Phrase.from(context.getText(R.string.proPlanRenewStart)) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format() - - is SubscriptionType.Active.Expiring -> Phrase.from(context.getText(R.string.proPlanActivatedNotAuto)) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(DATE_KEY, planData.subscriptionType.duration.expiryFromNow()) + val title = when (planData.proStatus) { + is ProStatus.Active.Expiring -> Phrase.from(context.getText(R.string.proAccessActivatedNotAuto)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(DATE_KEY, planData.proStatus.duration.expiryFromNow()) .format() - is SubscriptionType.Active.AutoRenewing -> Phrase.from(context.getText(R.string.proPlanActivatedAuto)) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(CURRENT_PLAN_KEY, DateUtils.getLocalisedTimeDuration( - context = context, - amount = planData.subscriptionType.duration.duration.months, - unit = MeasureUnit.MONTH - )) - .put(DATE_KEY, planData.subscriptionType.duration.expiryFromNow()) + is ProStatus.Active.AutoRenewing -> Phrase.from(context.getText(R.string.proAccessActivatesAuto)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put( + CURRENT_PLAN_LENGTH_KEY, DateUtils.getLocalisedTimeDuration( + context = context, + amount = planData.proStatus.duration.duration.months, + unit = MeasureUnit.MONTH + ) + ) + .put(DATE_KEY, planData.proStatus.duration.expiryFromNow()) .format() - else -> "" + else -> + Phrase.from(context.getText(R.string.proChooseAccess)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() } Text( @@ -148,21 +138,29 @@ fun ChoosePlan( style = LocalType.current.base, color = LocalColors.current.text, - ) + ) Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) // SUBSCRIPTIONS planData.plans.forEachIndexed { index, data -> - if(index != 0){ - Spacer(Modifier.height(if(data.badges.isNotEmpty()){ - max(LocalDimensions.current.xsSpacing, LocalDimensions.current.contentSpacing - badgeHeight/2) - } else LocalDimensions.current.contentSpacing)) + if (index != 0) { + Spacer( + Modifier.height( + if (data.badges.isNotEmpty()) { + max( + LocalDimensions.current.xsSpacing, + LocalDimensions.current.contentSpacing - badgeHeight / 2 + ) + } else LocalDimensions.current.contentSpacing + ) + ) } PlanItem( proPlan = data, badgePadding = badgeHeight / 2, onBadgeLaidOut = { height -> badgeHeight = max(badgeHeight, height) }, + enabled = !planData.purchaseInProgress, onClick = { sendCommand(SelectProPlan(data)) } @@ -171,24 +169,52 @@ fun ChoosePlan( Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) - val buttonLabel = when(planData.subscriptionType) { - is SubscriptionType.Expired -> context.getString(R.string.renew) - is SubscriptionType.Active.Expiring -> context.getString(R.string.updatePlan) - else -> context.getString(R.string.updatePlan) + val buttonLabel = when (planData.proStatus) { + is ProStatus.Expired -> context.getString(R.string.renew) + is ProStatus.Active.Expiring -> Phrase.from(LocalContext.current, R.string.updateAccess) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() + is ProStatus.NeverSubscribed -> stringResource(R.string.upgrade) + else -> Phrase.from(LocalContext.current, R.string.updateAccess) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() } AccentFillButtonRect( modifier = Modifier.fillMaxWidth() .widthIn(max = LocalDimensions.current.maxContentWidth), - text = buttonLabel, enabled = planData.enableButton, onClick = { sendCommand(GetProPlan) } - ) + ){ + LoadingArcOr(loading = planData.purchaseInProgress) { + Text(text = buttonLabel) + } + } Spacer(Modifier.height(LocalDimensions.current.xxsSpacing)) + val (footerAction, footerActivation) = when (planData.proStatus) { + is ProStatus.Expired -> + stringResource(R.string.proRenewingAction) to + stringResource(R.string.proReactivatingActivation) + + + is ProStatus.Active -> stringResource(R.string.proUpdatingAction) to "" + + is ProStatus.NeverSubscribed -> + stringResource(R.string.proUpgradingAction) to + stringResource(R.string.proActivatingActivation) + + } + + val footer = Phrase.from(LocalContext.current.getText(R.string.noteTosPrivacyPolicy)) + .put(ACTION_TYPE_KEY, footerAction) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(ICON_KEY, iconExternalLink) + .format() + Text( modifier = Modifier.fillMaxWidth() .clickable( @@ -201,21 +227,35 @@ fun ChoosePlan( vertical = LocalDimensions.current.xxsSpacing ) .clip(MaterialTheme.shapes.extraSmall), - text = annotatedStringResource( - Phrase.from(LocalContext.current.getText(R.string.proTosPrivacy)) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(ICON_KEY, iconExternalLink) - .put(ICON_KEY, iconExternalLink) - .format() - ), + text = annotatedStringResource(footer), textAlign = TextAlign.Center, - style = LocalType.current.small, + style = LocalType.current.base, color = LocalColors.current.text, inlineContent = inlineContentMap( textSize = LocalType.current.small.fontSize, imageColor = LocalColors.current.text ), ) + + // add another label in cases other than an active subscription + if (planData.proStatus !is ProStatus.Active) { + Spacer(Modifier.height(LocalDimensions.current.xxxsSpacing)) + Text( + modifier = Modifier.fillMaxWidth(), + text = annotatedStringResource( + Phrase.from(LocalContext.current.getText(R.string.proTosDescription)) + .put(ACTION_TYPE_KEY, footerAction) + .put(ACTIVATION_TYPE_KEY, footerActivation) + .put(ENTITY_KEY, NonTranslatableStringConstants.ENTITY_STF) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .format() + ), + textAlign = TextAlign.Center, + style = LocalType.current.base, + color = LocalColors.current.text, + ) + } } } @@ -223,6 +263,7 @@ fun ChoosePlan( private fun PlanItem( proPlan: ProPlan, badgePadding: Dp, + enabled: Boolean, modifier: Modifier= Modifier, onBadgeLaidOut: (Dp) -> Unit, onClick: () -> Unit @@ -245,9 +286,13 @@ private fun PlanItem( shape = MaterialTheme.shapes.small ) .clip(MaterialTheme.shapes.small) - .clickable( - onClick = onClick - ) + .then( + if (enabled) Modifier.clickable( + onClick = onClick + ) + else Modifier + ), + ) { Row( modifier = Modifier.fillMaxWidth() @@ -272,7 +317,7 @@ private fun PlanItem( RadioButtonIndicator( selected = proPlan.selected, - enabled = true, + enabled = enabled, colors = radioButtonColors( unselectedBorder = LocalColors.current.borders, selectedBorder = LocalColors.current.accent, @@ -382,6 +427,7 @@ private fun PreviewUpdatePlanItems( ), badgePadding = 0.dp, onBadgeLaidOut = {}, + enabled = true, onClick = {} ) @@ -398,26 +444,28 @@ private fun PreviewUpdatePlanItems( ), ), badgePadding = 0.dp, + enabled = true, onBadgeLaidOut = {}, onClick = {} ) PlanItem( - proPlan = ProSettingsViewModel.ProPlan( + proPlan = ProPlan( title = "Plan 1 with a very long title boo foo bar hello there", subtitle = "Subtitle that is also very long and is allowed to go onto another line", selected = true, currentPlan = true, durationType = ProSubscriptionDuration.TWELVE_MONTHS, badges = listOf( - ProSettingsViewModel.ProPlanBadge("Current Plan"), - ProSettingsViewModel.ProPlanBadge( + ProPlanBadge("Current Plan"), + ProPlanBadge( "20% Off but that is very long so we can test how this renders to be safe", "This is a tooltip" ), ), ), badgePadding = 0.dp, + enabled = true, onBadgeLaidOut = {}, onClick = {} ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt new file mode 100644 index 0000000000..135bad2487 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.preferences.prosettings.chooseplan + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import org.thoughtcrime.securesms.preferences.prosettings.BaseStateProScreen +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel +import org.thoughtcrime.securesms.pro.ProStatus +import org.thoughtcrime.securesms.pro.isFromAnotherPlatform + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun ChoosePlanHomeScreen( + viewModel: ProSettingsViewModel, + onBack: () -> Unit, +) { + LaunchedEffect(Unit) { + // ensuring we get the latest data here + // since we can deep link to this screen without going through the pro home screen + viewModel.ensureChoosePlanState() + } + + val state by viewModel.choosePlanState.collectAsState() + + BaseStateProScreen( + state = state, + onBack = onBack + ) { planData -> + // Option 1. ACTIVE Pro subscription + if(planData.proStatus is ProStatus.Active) { + val subscription = planData.proStatus + + when { + // there is an active subscription but from a different platform or from the + // same platform but a different account + // or we have no billing APIs + subscription.providerData.isFromAnotherPlatform() + || !planData.hasValidSubscription + || !planData.hasBillingCapacity -> + ChoosePlanNonOriginating( + subscription = planData.proStatus, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + + // default plan chooser + else -> ChoosePlan( + planData = planData, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + } + } else { // Option 2. Get brand new or Renew plan + when { + // there are no billing options on this device + !planData.hasBillingCapacity -> + ChoosePlanNoBilling( + subscription = planData.proStatus, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + + // default plan chooser + else -> ChoosePlan( + planData = planData, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt new file mode 100644 index 0000000000..0c1a4be12b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt @@ -0,0 +1,242 @@ +package org.thoughtcrime.securesms.preferences.prosettings.chooseplan + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.squareup.phrase.Phrase +import network.loki.messenger.BuildConfig +import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.BUILD_VARIANT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.ICON_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACCOUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE2_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY +import org.thoughtcrime.securesms.preferences.prosettings.BaseNonOriginatingProSettingsScreen +import org.thoughtcrime.securesms.preferences.prosettings.NonOriginatingLinkCellData +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog +import org.thoughtcrime.securesms.pro.ProStatus +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.pro.getPlatformDisplayName +import org.thoughtcrime.securesms.pro.previewExpiredApple +import org.thoughtcrime.securesms.ui.components.iconExternalLink +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) +@Composable +fun ChoosePlanNoBilling( + subscription: ProStatus, + sendCommand: (ProSettingsViewModel.Commands) -> Unit, + onBack: () -> Unit, +){ + val context = LocalContext.current + + val defaultGoogleStore = ProStatusManager.DEFAULT_GOOGLE_STORE + val defaultAppleStore = ProStatusManager.DEFAULT_APPLE_STORE + + val headerTitle = when(subscription) { + is ProStatus.Expired -> Phrase.from(context.getText(R.string.proAccessRenewStart)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + + is ProStatus.NeverSubscribed -> Phrase.from(context.getText(R.string.proUpgradeAccess)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + + else -> "" + } + + val contentTitle = when(subscription) { + is ProStatus.Expired -> Phrase.from(context.getText(R.string.renewingPro)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() + + is ProStatus.NeverSubscribed-> Phrase.from(context.getText(R.string.proUpgradingTo)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() + + else -> "" + } + + val contentDescription: CharSequence = when(subscription) { + is ProStatus.Expired -> Phrase.from(context.getText(R.string.proRenewingNoAccessBilling)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(PLATFORM_STORE_KEY, defaultGoogleStore) + .put(PLATFORM_STORE2_KEY, defaultAppleStore) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .put(BUILD_VARIANT_KEY, when (BuildConfig.FLAVOR) { + "fdroid" -> "F-Droid Store" + "huawei" -> "Huawei App Gallery" + else -> "APK" + }) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(PLATFORM_STORE_KEY, defaultGoogleStore) + .put(PLATFORM_STORE2_KEY, defaultAppleStore) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(ICON_KEY, iconExternalLink) + .format() + + is ProStatus.NeverSubscribed -> Phrase.from(context.getText(R.string.proUpgradeNoAccessBilling)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(PLATFORM_STORE_KEY, defaultGoogleStore) + .put(PLATFORM_STORE2_KEY, defaultAppleStore) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .put(BUILD_VARIANT_KEY, when (BuildConfig.FLAVOR) { + "fdroid" -> "F-Droid Store" + "huawei" -> "Huawei App Gallery" + else -> "APK" + }) + .put(ICON_KEY, iconExternalLink) + .format() + + else -> "" + } + + val cellsInfo = when(subscription) { + is ProStatus.Expired -> stringResource(R.string.proOptionsRenewalSubtitle) + is ProStatus.NeverSubscribed -> stringResource(R.string.proUpgradeOptionsTwo) + else -> "" + } + + val cell1Text: CharSequence = when(subscription) { + is ProStatus.Expired -> Phrase.from(context.getText(R.string.proRenewDesktopLinked)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .put(PLATFORM_STORE_KEY, defaultGoogleStore) + .put(PLATFORM_STORE2_KEY, defaultAppleStore) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + + is ProStatus.NeverSubscribed -> Phrase.from(context.getText(R.string.proUpgradeDesktopLinked)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .put(PLATFORM_STORE_KEY, defaultGoogleStore) + .put(PLATFORM_STORE2_KEY, defaultAppleStore) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + + else -> "" + } + + val cell2Text: CharSequence = when(subscription) { + is ProStatus.Expired -> Phrase.from(context.getText(R.string.proNewInstallationDescription)) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .put(PLATFORM_STORE_KEY, defaultGoogleStore) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + + is ProStatus.NeverSubscribed -> Phrase.from(context.getText(R.string.proNewInstallationUpgrade)) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .put(PLATFORM_STORE_KEY, defaultGoogleStore) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + + else -> "" + } + + val cells: List = buildList { + // cell 1 + add( + NonOriginatingLinkCellData( + title = stringResource(R.string.onLinkedDevice), + info = cell1Text, + iconRes = R.drawable.ic_link + ) + ) + + // cell 2 + add( + NonOriginatingLinkCellData( + title = stringResource(R.string.proNewInstallation), + info = cell2Text, + iconRes = R.drawable.ic_smartphone + ) + ) + + // optional cell 3 + if(subscription is ProStatus.Expired) { + add( + NonOriginatingLinkCellData( + title = Phrase.from(context.getText(R.string.onPlatformStoreWebsite)) + .put(PLATFORM_STORE_KEY, subscription.providerData.getPlatformDisplayName()) + .format(), + info = Phrase.from(context.getText(R.string.proAccessRenewPlatformWebsite)) + .put(PLATFORM_KEY, subscription.providerData.getPlatformDisplayName()) + .put(PLATFORM_ACCOUNT_KEY, subscription.providerData.platformAccount) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format(), + iconRes = R.drawable.ic_globe + ) + ) + } + } + + + BaseNonOriginatingProSettingsScreen( + disabled = false, + onBack = onBack, + headerTitle = headerTitle, + buttonText = if(subscription is ProStatus.Expired) Phrase.from(context.getText(R.string.openPlatformWebsite)) + .put(PLATFORM_KEY, subscription.providerData.getPlatformDisplayName()) + .format().toString() + else null, + dangerButton = false, + onButtonClick = { + if(subscription is ProStatus.Expired) { + sendCommand(ShowOpenUrlDialog(subscription.providerData.updateSubscriptionUrl)) + } + }, + contentTitle = contentTitle, + contentDescription = contentDescription, + contentClick = { + sendCommand(ShowOpenUrlDialog("https://getsession.org/pro-roadmap")) + }, + linkCellsInfo = cellsInfo, + linkCells = cells + ) +} + + +@Preview +@Composable +private fun PreviewNonOrigExpiredUpdatePlan( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + val context = LocalContext.current + ChoosePlanNoBilling ( + subscription = previewExpiredApple, + sendCommand = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewNoBiilingBrandNewPlan( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + val context = LocalContext.current + ChoosePlanNoBilling ( + subscription = ProStatus.NeverSubscribed, + sendCommand = {}, + onBack = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt similarity index 53% rename from app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt rename to app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt index e9f9f997db..e262b7a669 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt @@ -1,11 +1,10 @@ -package org.thoughtcrime.securesms.preferences.prosettings +package org.thoughtcrime.securesms.preferences.prosettings.chooseplan import android.icu.util.MeasureUnit import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import com.squareup.phrase.Phrase @@ -13,87 +12,101 @@ import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.CURRENT_PLAN_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.CURRENT_PLAN_LENGTH_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DEVICE_TYPE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACCOUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY -import org.session.libsession.utilities.recipients.ProStatus -import org.thoughtcrime.securesms.pro.SubscriptionType -import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY +import org.thoughtcrime.securesms.preferences.prosettings.BaseNonOriginatingProSettingsScreen +import org.thoughtcrime.securesms.preferences.prosettings.NonOriginatingLinkCellData +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog +import org.thoughtcrime.securesms.pro.ProStatus +import org.thoughtcrime.securesms.pro.getPlatformDisplayName +import org.thoughtcrime.securesms.pro.previewAutoRenewingApple import org.thoughtcrime.securesms.pro.subscription.expiryFromNow import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.util.DateUtils -import java.time.Duration -import java.time.Instant @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable fun ChoosePlanNonOriginating( - subscription: SubscriptionType.Active, + subscription: ProStatus.Active, sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ){ - val nonOriginatingData = subscription.nonOriginatingSubscription ?: return val context = LocalContext.current + val platformOverride = subscription.providerData.getPlatformDisplayName() + val headerTitle = when(subscription) { - is SubscriptionType.Active.Expiring -> Phrase.from(context.getText(R.string.proPlanExpireDate)) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + is ProStatus.Active.Expiring -> Phrase.from(context.getText(R.string.proAccessExpireDate)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(DATE_KEY, subscription.duration.expiryFromNow()) .format() - else -> Phrase.from(context.getText(R.string.proPlanActivatedAutoShort)) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(CURRENT_PLAN_KEY, DateUtils.getLocalisedTimeDuration( + is ProStatus.Active.AutoRenewing -> Phrase.from(context.getText(R.string.proAccessActivatedAutoShort)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(CURRENT_PLAN_LENGTH_KEY, DateUtils.getLocalisedTimeDuration( context = context, amount = subscription.duration.duration.months, unit = MeasureUnit.MONTH )) .put(DATE_KEY, subscription.duration.expiryFromNow()) .format() + + else -> "" } BaseNonOriginatingProSettingsScreen( disabled = false, onBack = onBack, headerTitle = headerTitle, - buttonText = Phrase.from(context.getText(R.string.openStoreWebsite)) - .put(PLATFORM_STORE_KEY, nonOriginatingData.store) + buttonText = Phrase.from(context.getText(R.string.openPlatformWebsite)) + .put(PLATFORM_KEY, platformOverride) .format().toString(), dangerButton = false, onButtonClick = { - //todo PRO implement + sendCommand(ShowOpenUrlDialog(subscription.providerData.updateSubscriptionUrl)) }, - contentTitle = stringResource(R.string.updatePlan), - contentDescription = Phrase.from(context.getText(R.string.proPlanSignUp)) + contentTitle = Phrase.from(LocalContext.current, R.string.updateAccess) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), + contentDescription = Phrase.from(context.getText(R.string.proAccessSignUp)) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(PLATFORM_STORE_KEY, nonOriginatingData.store) - .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) + .put(PLATFORM_STORE_KEY, subscription.providerData.store) + .put(PLATFORM_ACCOUNT_KEY, subscription.providerData.platformAccount) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format(), - linkCellsInfo = stringResource(R.string.updatePlanTwo), + linkCellsInfo = Phrase.from(context.getText(R.string.updateAccessTwo)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), linkCells = listOf( NonOriginatingLinkCellData( - title = Phrase.from(context.getText(R.string.onDevice)) - .put(DEVICE_TYPE_KEY, nonOriginatingData.device) + title = Phrase.from(context.getText(R.string.onDevice)) + .put(DEVICE_TYPE_KEY, subscription.providerData.device) .format(), info = Phrase.from(context.getText(R.string.onDeviceDescription)) .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) - .put(DEVICE_TYPE_KEY, nonOriginatingData.device) - .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) + .put(DEVICE_TYPE_KEY, subscription.providerData.device) + .put(PLATFORM_ACCOUNT_KEY, subscription.providerData.platformAccount) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format(), iconRes = R.drawable.ic_smartphone ), NonOriginatingLinkCellData( - title = Phrase.from(context.getText(R.string.viaStoreWebsite)) - .put(PLATFORM_STORE_KEY, nonOriginatingData.store) + title = Phrase.from(context.getText(R.string.viaStoreWebsite)) + .put(PLATFORM_KEY, platformOverride) .format(), info = Phrase.from(context.getText(R.string.viaStoreWebsiteDescription)) - .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) - .put(PLATFORM_STORE_KEY, nonOriginatingData.store) + .put(PLATFORM_ACCOUNT_KEY, subscription.providerData.platformAccount) + .put(PLATFORM_STORE_KEY, platformOverride) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format(), iconRes = R.drawable.ic_globe ) @@ -109,20 +122,7 @@ private fun PreviewUpdatePlan( PreviewTheme(colors) { val context = LocalContext.current ChoosePlanNonOriginating ( - subscription = SubscriptionType.Active.AutoRenewing( - proStatus = ProStatus.Pro( - visible = true, - validUntil = Instant.now() + Duration.ofDays(14), - ), - duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = SubscriptionType.Active.NonOriginatingSubscription( - device = "iPhone", - store = "Apple App Store", - platform = "Apple", - platformAccount = "Apple Account", - urlSubscription = "https://www.apple.com/account/subscriptions", - ) - ), + subscription = previewAutoRenewingApple, sendCommand = {}, onBack = {}, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ConfigExt.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ConfigExt.kt new file mode 100644 index 0000000000..85c8432bd0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ConfigExt.kt @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.pro + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import network.loki.messenger.libsession_util.pro.ProConfig +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.UserConfigType +import org.session.libsession.utilities.userConfigsChanged +import org.thoughtcrime.securesms.util.castAwayType +import java.util.EnumSet + +fun ConfigFactoryProtocol.watchUserProConfig(): Flow = + userConfigsChanged(EnumSet.of(UserConfigType.USER_PROFILE)) + .castAwayType() + .onStart { emit(Unit) } + .map { + withUserConfigs { configs -> + configs.userProfile.getProConfig() + } + } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt new file mode 100644 index 0000000000..3df0e146d3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt @@ -0,0 +1,169 @@ +package org.thoughtcrime.securesms.pro + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkQuery +import androidx.work.WorkerParameters +import androidx.work.await +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapNotNull +import org.session.libsession.snode.SnodeClock +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsignal.exceptions.NonRetryableException +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.auth.LoginStateRepository +import org.thoughtcrime.securesms.pro.api.GetProDetailsRequest +import org.thoughtcrime.securesms.pro.api.ProApiExecutor +import org.thoughtcrime.securesms.pro.api.ProDetails +import org.thoughtcrime.securesms.pro.api.successOrThrow +import org.thoughtcrime.securesms.pro.db.ProDatabase +import java.time.Duration + +/** + * A worker that fetches the user's Pro details from the server and updates the local database. + * + * This worker doesn't do any business logic in terms of when to schedule itself, it simply performs + * the fetch and update operation regardlessly. It, however, does schedule the [ProProofGenerationWorker] + * if needed based on the fetched Pro details, this is because the proof generation logic + * is tightly coupled to the fetched Pro details state. + */ +@HiltWorker +class FetchProDetailsWorker @AssistedInject constructor( + @Assisted private val context: Context, + @Assisted params: WorkerParameters, + private val apiExecutor: ProApiExecutor, + private val getProDetailsRequestFactory: GetProDetailsRequest.Factory, + private val proDatabase: ProDatabase, + private val loginStateRepository: LoginStateRepository, + private val snodeClock: SnodeClock, + private val configFactory: ConfigFactoryProtocol, +) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + val proMasterKey = + requireNotNull(loginStateRepository.peekLoginState()?.seeded?.proMasterPrivateKey) { + "User must be logged in to fetch pro details" + } + + return try { + Log.d(TAG, "Fetching Pro details from server") + val details = apiExecutor.executeRequest( + request = getProDetailsRequestFactory.create(proMasterKey) + ).successOrThrow() + + Log.d( + TAG, + "Fetched pro details, status = ${details.status}, expiry = ${details.expiry}" + ) + + configFactory.withMutableUserConfigs { + if (details.expiry != null) { + it.userProfile.setProAccessExpiryMs(details.expiry.toEpochMilli()) + } else { + it.userProfile.removeProAccessExpiry() + } + } + proDatabase.updateProDetails(proDetails = details, updatedAt = snodeClock.currentTime()) + + scheduleProofGenerationIfNeeded(details) + + Result.success() + } catch (e: CancellationException) { + Log.d(TAG, "Work cancelled") + throw e + } catch (e: NonRetryableException) { + Log.e(TAG, "Non-retryable error fetching pro details", e) + Result.failure() + } catch (e: Exception) { + Log.e(TAG, "Error fetching pro details", e) + Result.retry() + } + } + + + private suspend fun scheduleProofGenerationIfNeeded(details: ProDetails) { + val now = snodeClock.currentTimeMills() + + if (details.status != ProDetails.DETAILS_STATUS_ACTIVE) { + Log.d(TAG, "Pro is not active, clearing proof") + ProProofGenerationWorker.cancel(context) + configFactory.withMutableUserConfigs { + it.userProfile.removeProConfig() + } + } else { + val currentProof = configFactory.withUserConfigs { it.userProfile.getProConfig() }?.proProof + + if (currentProof == null || currentProof.expiryMs <= now) { + Log.d( + TAG, + "Pro is active but no valid proof found, scheduling proof generation now" + ) + ProProofGenerationWorker.schedule(context) + } else if (currentProof.expiryMs - now <= Duration.ofMinutes(60).toMillis() && + details.expiry!!.toEpochMilli() - now > Duration.ofMinutes(60).toMillis() && + details.autoRenewing == true + ) { + val delay = Duration.ofMinutes((Math.random() * 50 + 10).toLong()) + Log.d(TAG, "Pro proof is expiring soon, scheduling proof generation in $delay") + ProProofGenerationWorker.schedule(context, delay) + } else { + Log.d( + TAG, + "Pro proof is still valid for a long period, no need to schedule proof generation" + ) + } + } + } + + companion object { + private const val TAG = "FetchProDetailsWorker" + + fun watch(context: Context): Flow { + val workQuery = WorkQuery.Builder + .fromUniqueWorkNames(listOf(TAG)) + .build() + + return WorkManager.getInstance(context) + .getWorkInfosFlow(workQuery) + .mapNotNull { it.firstOrNull() } + } + + fun schedule( + context: Context, + existingWorkPolicy: ExistingWorkPolicy, + delay: Duration? = null + ) { + WorkManager.getInstance(context) + .enqueueUniqueWork( + uniqueWorkName = TAG, + existingWorkPolicy = existingWorkPolicy, + request = OneTimeWorkRequestBuilder() + .apply { + if (delay != null) { + setInitialDelay(delay) + } + } + .addTag(TAG) + .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED)) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofSeconds(10)) + .build() + ) + } + + suspend fun cancel(context: Context) { + WorkManager.getInstance(context) + .cancelUniqueWork(TAG) + .await() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt new file mode 100644 index 0000000000..873d0cd6a0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt @@ -0,0 +1,108 @@ +package org.thoughtcrime.securesms.pro + +import network.loki.messenger.libsession_util.pro.BackendRequests +import network.loki.messenger.libsession_util.pro.BackendRequests.PAYMENT_PROVIDER_APP_STORE +import network.loki.messenger.libsession_util.pro.BackendRequests.PAYMENT_PROVIDER_GOOGLE_PLAY +import network.loki.messenger.libsession_util.pro.PaymentProvider +import network.loki.messenger.libsession_util.protocol.PaymentProviderMetadata +import org.thoughtcrime.securesms.pro.api.ServerPlanDuration +import org.thoughtcrime.securesms.pro.api.ProDetails +import org.thoughtcrime.securesms.pro.api.ProDetails.Companion.SERVER_PLAN_DURATION_12_MONTH +import org.thoughtcrime.securesms.pro.api.ProDetails.Companion.SERVER_PLAN_DURATION_3_MONTH +import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import java.time.Duration +import java.time.Instant + +fun ProDetails.toProStatus(): ProStatus { + return when (status) { + ProDetails.DETAILS_STATUS_ACTIVE -> { + val paymentItem = paymentItems.first() + + if (autoRenewing == true) { + ProStatus.Active.AutoRenewing( + validUntil = expiry!!, + duration = paymentItem.planDuration.toSubscriptionDuration(), + providerData = paymentItem.paymentProvider.getMetadata(), + quickRefundExpiry = paymentItem.platformExpiry, + refundInProgress = refundRequestedAtMs > 0 + ) + } else { + ProStatus.Active.Expiring( + validUntil = expiry!!, + duration = paymentItem.planDuration.toSubscriptionDuration(), + providerData = paymentItem.paymentProvider.getMetadata(), + quickRefundExpiry = paymentItem.platformExpiry, + refundInProgress = refundRequestedAtMs > 0 + ) + } + } + + ProDetails.DETAILS_STATUS_EXPIRED -> ProStatus.Expired( + expiredAt = expiry!!, + providerData = paymentItems.first().paymentProvider.getMetadata() + ) + + else -> ProStatus.NeverSubscribed + } +} + +fun PaymentProvider.getMetadata(): PaymentProviderMetadata{ + return when(this){ + PAYMENT_PROVIDER_APP_STORE -> BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_APP_STORE)!! + else -> BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY)!! + } +} + +fun ServerPlanDuration.toSubscriptionDuration(): ProSubscriptionDuration { + return when(this){ + SERVER_PLAN_DURATION_12_MONTH -> ProSubscriptionDuration.TWELVE_MONTHS + SERVER_PLAN_DURATION_3_MONTH -> ProSubscriptionDuration.THREE_MONTHS + else -> ProSubscriptionDuration.ONE_MONTH + } +} + +fun PaymentProviderMetadata.isFromAnotherPlatform(): Boolean { + return platform.trim().lowercase() != "google" +} + +/** + * Some UI cases require a special display name for the platform. + */ +fun PaymentProviderMetadata.getPlatformDisplayName(): String { + return when(platform.trim().lowercase()){ + "google" -> store + else -> platform + } +} + + +/** + * Preview Data - Reusable data for composable previews + */ + +val previewAppleMetaData = PaymentProviderMetadata( + device = "iOS", + store = "Apple App Store", + platform = "Apple", + platformAccount = "Apple Account", + updateSubscriptionUrl = "https://www.apple.com/account/subscriptions", + cancelSubscriptionUrl = "https://www.apple.com/account/subscriptions", + refundPlatformUrl = "https://www.apple.com/account/subscriptions", + refundSupportUrl = "https://www.apple.com/account/subscriptions", + refundStatusUrl = "https://www.apple.com/account/subscriptions" +) + +val previewAutoRenewingApple = ProStatus.Active.AutoRenewing( + validUntil = Instant.now() + Duration.ofDays(14), + duration = ProSubscriptionDuration.THREE_MONTHS, + providerData = previewAppleMetaData, + quickRefundExpiry = Instant.now() + Duration.ofDays(14), + refundInProgress = false +) + +val previewExpiredApple = ProStatus.Expired( + expiredAt = Instant.now() - Duration.ofDays(14), + providerData = previewAppleMetaData +) + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt new file mode 100644 index 0000000000..947a260654 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt @@ -0,0 +1,98 @@ +package org.thoughtcrime.securesms.pro + +import android.app.Application +import androidx.work.ExistingWorkPolicy +import androidx.work.WorkInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import org.session.libsession.snode.SnodeClock +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.debugmenu.DebugLogGroup +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.pro.api.ProDetails +import org.thoughtcrime.securesms.pro.db.ProDatabase +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ProDetailsRepository @Inject constructor( + private val application: Application, + private val db: ProDatabase, + private val snodeClock: SnodeClock, + @ManagerScope scope: CoroutineScope, +) { + sealed interface LoadState { + val lastUpdated: Pair? + + data object Init : LoadState { + override val lastUpdated: Pair? + get() = null + } + + data class Loading( + override val lastUpdated: Pair?, + val waitingForNetwork: Boolean + ) : LoadState + + data class Loaded(override val lastUpdated: Pair) : LoadState + data class Error(override val lastUpdated: Pair?) : LoadState + } + + + val loadState: StateFlow = combine( + FetchProDetailsWorker.watch(application) + .map { it.state } + .distinctUntilChanged(), + + db.proDetailsChangeNotification + .onStart { emit(Unit) } + .map { db.getProDetailsAndLastUpdated() } + ) { state, last -> + when (state) { + WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> LoadState.Loading(last, waitingForNetwork = true) + WorkInfo.State.RUNNING -> LoadState.Loading(last, waitingForNetwork = false) + WorkInfo.State.SUCCEEDED -> { + if (last != null) { + Log.d(DebugLogGroup.PRO_DATA.label, "Successfully fetched Pro details from backend") + LoadState.Loaded(last) + } else { + // This should never happen, but just in case... + LoadState.Error(null) + } + } + + WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> LoadState.Error(last) + } + }.stateIn(scope, SharingStarted.Eagerly, LoadState.Init) + + + /** + * Requests a fresh of current user's pro details. By default, if last update is recent enough, + * no network request will be made. If [force] is true, a network request will be + * made regardless of the freshness of the last update. + */ + fun requestRefresh(force: Boolean = false) { + val currentState = loadState.value + if (!force && (currentState is LoadState.Loading || currentState is LoadState.Loaded) && + currentState.lastUpdated?.second?.plusSeconds(MIN_UPDATE_INTERVAL_SECONDS) + ?.isAfter(snodeClock.currentTime()) == true) { + Log.d(DebugLogGroup.PRO_DATA.label, "Pro details are fresh enough, skipping refresh") + return + } + + Log.d(DebugLogGroup.PRO_DATA.label, "Scheduling fetch of Pro details from server") + FetchProDetailsWorker.schedule(application, ExistingWorkPolicy.KEEP) + } + + + companion object { + private const val MIN_UPDATE_INTERVAL_SECONDS = 60L + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProFeatureExt.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProFeatureExt.kt new file mode 100644 index 0000000000..86442e9602 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProFeatureExt.kt @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.pro + +import network.loki.messenger.libsession_util.protocol.ProFeature +import network.loki.messenger.libsession_util.protocol.ProMessageFeature +import network.loki.messenger.libsession_util.protocol.ProProfileFeature +import network.loki.messenger.libsession_util.util.BitSet +import network.loki.messenger.libsession_util.util.asSequence +import network.loki.messenger.libsession_util.util.toBitSet + +fun Long.toProMessageFeatures(out: MutableCollection) { + out.addAll(BitSet(this).asSequence()) +} + +fun Long.toProProfileFeatures(out: MutableCollection) { + out.addAll(BitSet(this).asSequence()) +} + +fun Iterable.toProMessageBitSetValue(): Long { + return filterIsInstance().toBitSet().rawValue +} + +fun Iterable.toProProfileBitSetValue(): Long { + return filterIsInstance().toBitSet().rawValue +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt new file mode 100644 index 0000000000..4617a46315 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt @@ -0,0 +1,121 @@ +package org.thoughtcrime.securesms.pro + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.await +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException +import network.loki.messenger.libsession_util.ED25519 +import network.loki.messenger.libsession_util.pro.ProConfig +import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsignal.exceptions.NonRetryableException +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.auth.LoginStateRepository +import org.thoughtcrime.securesms.pro.api.GenerateProProofRequest +import org.thoughtcrime.securesms.pro.api.ProApiExecutor +import org.thoughtcrime.securesms.pro.api.ProDetails +import org.thoughtcrime.securesms.pro.api.successOrThrow +import org.thoughtcrime.securesms.util.getRootCause +import java.time.Duration +import java.time.Instant + +/** + * A worker that generates a new [network.loki.messenger.libsession_util.pro.ProProof] and stores it + * locally. + * + * Normally you don't need to interact with this worker directly, as it is scheduled + * automatically when needed based on the Pro details state, by the [FetchProDetailsWorker]. + */ +@HiltWorker +class ProProofGenerationWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val proApiExecutor: ProApiExecutor, + private val generateProProofRequest: GenerateProProofRequest.Factory, + private val proDetailsRepository: ProDetailsRepository, + private val loginStateRepository: LoginStateRepository, + private val configFactory: ConfigFactoryProtocol, +) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + val proMasterKey = requireNotNull(loginStateRepository.peekLoginState()?.seeded?.proMasterPrivateKey) { + "User must be logged to generate proof" + } + + val details = checkNotNull(proDetailsRepository.loadState.value.lastUpdated) { + "Pro details must be available to generate proof" + } + + check(details.first.status == ProDetails.DETAILS_STATUS_ACTIVE) { + "Pro status must be active to generate proof" + } + + return try { + val rotatingPrivateKey = ED25519.generate(null).secretKey.data + + val proof = proApiExecutor.executeRequest( + request = generateProProofRequest.create( + masterPrivateKey = proMasterKey, + rotatingPrivateKey = rotatingPrivateKey + ) + ).successOrThrow() + + configFactory.withMutableUserConfigs { + it.userProfile.setProConfig(ProConfig( + proProof = proof, + rotatingPrivateKey = rotatingPrivateKey)) + } + + + Log.d(WORK_NAME, "Successfully generated a new pro proof expiring at ${Instant.ofEpochMilli(proof.expiryMs)}") + Result.success() + } catch (e: Exception) { + if (e is CancellationException) throw e + + Log.e(WORK_NAME, "Error generating Pro proof", e) + if (e is NonRetryableException || + // HTTP 403 indicates that the user is not + e.getRootCause()?.statusCode == 403) { + Result.failure() + } else { + Result.retry() + } + } + } + + companion object { + private const val WORK_NAME = "ProProofGenerationWorker" + + suspend fun schedule(context: Context, delay: Duration? = null) { + WorkManager.getInstance(context) + .enqueueUniqueWork(WORK_NAME, + ExistingWorkPolicy.REPLACE, + OneTimeWorkRequestBuilder() + .apply { + if (delay != null) { + setInitialDelay(delay) + } + } + .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED)) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofSeconds(10)) + .build() + ) + .await() + } + + suspend fun cancel(context: Context) { + WorkManager.getInstance(context) + .cancelUniqueWork(WORK_NAME) + .await() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofs.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofs.kt new file mode 100644 index 0000000000..1d13b218f4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofs.kt @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.pro + +import com.google.protobuf.ByteString +import network.loki.messenger.libsession_util.pro.ProProof +import org.session.libsignal.protos.SignalServiceProtos + +/** + * Copies values from a libsession ProProof into a protobuf-based ProProof. + */ +fun SignalServiceProtos.ProProof.Builder.copyFromLibSession( + proProof: ProProof +): SignalServiceProtos.ProProof.Builder = setVersion(proProof.version) + .setExpiryUnixTs(proProof.expiryMs) + .setGenIndexHash(ByteString.copyFrom(proProof.genIndexHashHex.hexToByteArray())) + .setRotatingPublicKey(ByteString.copyFrom(proProof.rotatingPubKeyHex.hexToByteArray())) + .setSig(ByteString.copyFrom(proProof.signatureHex.hexToByteArray())) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt new file mode 100644 index 0000000000..3f0d4aee49 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.pro + +import network.loki.messenger.libsession_util.protocol.PaymentProviderMetadata +import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.thoughtcrime.securesms.util.State +import java.time.Instant + +sealed interface ProStatus{ + data object NeverSubscribed: ProStatus + + sealed interface Active: ProStatus{ + val validUntil: Instant + val duration: ProSubscriptionDuration + val providerData: PaymentProviderMetadata + val quickRefundExpiry: Instant? + val refundInProgress: Boolean + + data class AutoRenewing( + override val validUntil: Instant, + override val duration: ProSubscriptionDuration, + override val providerData: PaymentProviderMetadata, + override val quickRefundExpiry: Instant?, + override val refundInProgress: Boolean + ): Active + + data class Expiring( + override val validUntil: Instant, + override val duration: ProSubscriptionDuration, + override val providerData: PaymentProviderMetadata, + override val quickRefundExpiry: Instant? + , + override val refundInProgress: Boolean + ): Active + + fun isWithinQuickRefundWindow(): Boolean { + return quickRefundExpiry != null && quickRefundExpiry!!.isAfter(Instant.now()) + } + + + } + + data class Expired( + val expiredAt: Instant, + val providerData: PaymentProviderMetadata + ): ProStatus +} + +data class ProDataState( + val type: ProStatus, + val showProBadge: Boolean, + val refreshState: State, +) + +fun getDefaultSubscriptionStateData() = ProDataState( + type = ProStatus.NeverSubscribed, + refreshState = State.Loading, + showProBadge = false +) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt index 01f922ab75..248c4338a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -1,110 +1,192 @@ package org.thoughtcrime.securesms.pro -import android.content.Context -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.GlobalScope +import android.app.Application +import dagger.Lazy +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.time.delay +import kotlinx.coroutines.withTimeout +import network.loki.messenger.libsession_util.ED25519 +import network.loki.messenger.libsession_util.pro.BackendRequests +import network.loki.messenger.libsession_util.pro.BackendRequests.PAYMENT_PROVIDER_APP_STORE +import network.loki.messenger.libsession_util.pro.BackendRequests.PAYMENT_PROVIDER_GOOGLE_PLAY +import network.loki.messenger.libsession_util.pro.ProConfig +import network.loki.messenger.libsession_util.protocol.ProFeature +import network.loki.messenger.libsession_util.util.Conversation import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.snode.SnodeClock +import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.recipients.ProStatus +import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.recipients.Recipient -import org.session.libsession.utilities.recipients.isPro -import org.session.libsession.utilities.recipients.shouldShowProBadge +import org.session.libsession.utilities.userConfigsChanged +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.RecipientRepository -import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel +import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog +import org.thoughtcrime.securesms.pro.api.AddPaymentErrorStatus +import org.thoughtcrime.securesms.pro.api.AddProPaymentRequest +import org.thoughtcrime.securesms.pro.api.ProApiExecutor +import org.thoughtcrime.securesms.pro.api.ProApiResponse +import org.thoughtcrime.securesms.pro.db.ProDatabase import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager import org.thoughtcrime.securesms.util.State import java.time.Duration import java.time.Instant +import java.util.EnumSet import javax.inject.Inject import javax.inject.Singleton +@OptIn(ExperimentalCoroutinesApi::class) @Singleton class ProStatusManager @Inject constructor( - @ApplicationContext private val context: Context, + private val application: Application, private val prefs: TextSecurePreferences, - private val recipientRepository: RecipientRepository, + recipientRepository: RecipientRepository, + @param:ManagerScope private val scope: CoroutineScope, + private val apiExecutor: ProApiExecutor, + private val loginState: LoginStateRepository, + private val proDatabase: ProDatabase, + private val snodeClock: SnodeClock, + private val proDetailsRepository: ProDetailsRepository, + private val configFactory: Lazy, ) : OnAppStartupComponent { - val subscriptionState: StateFlow = combine( - recipientRepository.observeSelf(), + val proDataState: StateFlow = combine( + recipientRepository.observeSelf().map { it.shouldShowProBadge }.distinctUntilChanged(), + proDetailsRepository.loadState, (TextSecurePreferences.events.filter { it == TextSecurePreferences.DEBUG_SUBSCRIPTION_STATUS } as Flow<*>) .onStart { emit(Unit) } - .map { prefs.getDebugSubscriptionType() } - ){ selfRecipient, debugSubscription -> - //todo PRO implement properly - - val subscriptionState = debugSubscription ?: DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE - SubscriptionState( - type = when(subscriptionState){ - DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE -> SubscriptionType.Active.AutoRenewing( - proStatus = ProStatus.Pro( - visible = true, + .map { prefs.getDebugSubscriptionType() }, + (TextSecurePreferences.events.filter { it == TextSecurePreferences.DEBUG_PRO_PLAN_STATUS } as Flow<*>) + .onStart { emit(Unit) } + .map { prefs.getDebugProPlanStatus() }, + (TextSecurePreferences.events.filter { it == TextSecurePreferences.SET_FORCE_CURRENT_USER_PRO } as Flow<*>) + .onStart { emit(Unit) } + .map { prefs.forceCurrentUserAsPro() }, + ){ shouldShowProBadge, proDetailsState, debugSubscription, debugProPlanStatus, forceCurrentUserAsPro -> + val proDataRefreshState = when(debugProPlanStatus){ + DebugMenuViewModel.DebugProPlanStatus.LOADING -> State.Loading + DebugMenuViewModel.DebugProPlanStatus.ERROR -> State.Error(Exception()) + else -> { + // calculate the real refresh state here + when(proDetailsState){ + is ProDetailsRepository.LoadState.Loading -> State.Loading + is ProDetailsRepository.LoadState.Error -> State.Error(Exception()) + else -> State.Success(Unit) + } + } + } + + if(!forceCurrentUserAsPro){ + Log.d(DebugLogGroup.PRO_DATA.label, "ProStatusManager: Getting REAL Pro data state") + + ProDataState( + type = proDetailsState.lastUpdated?.first?.toProStatus() ?: ProStatus.NeverSubscribed, + showProBadge = shouldShowProBadge, + refreshState = proDataRefreshState + ) + }// debug data + else { + Log.d(DebugLogGroup.PRO_DATA.label, "ProStatusManager: Getting DEBUG Pro data state") + val subscriptionState = debugSubscription ?: DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE + + ProDataState( + type = when(subscriptionState){ + DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE -> ProStatus.Active.AutoRenewing( + validUntil = Instant.now() + Duration.ofDays(14), + duration = ProSubscriptionDuration.THREE_MONTHS, + providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY)!!, + quickRefundExpiry = Instant.now() + Duration.ofDays(7), + refundInProgress = false + ) + + DebugMenuViewModel.DebugSubscriptionStatus.AUTO_APPLE_REFUNDING -> ProStatus.Active.AutoRenewing( validUntil = Instant.now() + Duration.ofDays(14), - ), - duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = null - ) - - DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_GOOGLE -> SubscriptionType.Active.Expiring( - proStatus = ProStatus.Pro( - visible = true, + duration = ProSubscriptionDuration.THREE_MONTHS, + providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_APP_STORE)!!, + quickRefundExpiry = Instant.now() + Duration.ofDays(7), + refundInProgress = true + ) + + DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_GOOGLE -> ProStatus.Active.Expiring( validUntil = Instant.now() + Duration.ofDays(2), - ), - duration = ProSubscriptionDuration.TWELVE_MONTHS, - nonOriginatingSubscription = null - ) - - DebugMenuViewModel.DebugSubscriptionStatus.AUTO_APPLE -> SubscriptionType.Active.AutoRenewing( - proStatus = ProStatus.Pro( - visible = true, + duration = ProSubscriptionDuration.TWELVE_MONTHS, + providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY)!!, + quickRefundExpiry = Instant.now() + Duration.ofDays(7), + refundInProgress = false + ) + + DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_GOOGLE_LATER -> ProStatus.Active.Expiring( + validUntil = Instant.now() + Duration.ofDays(40), + duration = ProSubscriptionDuration.TWELVE_MONTHS, + providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY)!!, + quickRefundExpiry = Instant.now() + Duration.ofDays(7), + refundInProgress = false + ) + + DebugMenuViewModel.DebugSubscriptionStatus.AUTO_APPLE -> ProStatus.Active.AutoRenewing( validUntil = Instant.now() + Duration.ofDays(14), - ), - duration = ProSubscriptionDuration.ONE_MONTH, - nonOriginatingSubscription = SubscriptionType.Active.NonOriginatingSubscription( - device = "iPhone", - store = "Apple App Store", - platform = "Apple", - platformAccount = "Apple Account", - urlSubscription = "https://www.apple.com/account/subscriptions", + duration = ProSubscriptionDuration.ONE_MONTH, + providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_APP_STORE)!!, + quickRefundExpiry = Instant.now() + Duration.ofDays(7), + refundInProgress = false ) - ) - DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_APPLE -> SubscriptionType.Active.Expiring( - proStatus = ProStatus.Pro( - visible = true, + DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_APPLE -> ProStatus.Active.Expiring( validUntil = Instant.now() + Duration.ofDays(2), - ), - duration = ProSubscriptionDuration.ONE_MONTH, - nonOriginatingSubscription = SubscriptionType.Active.NonOriginatingSubscription( - device = "iPhone", - store = "Apple App Store", - platform = "Apple", - platformAccount = "Apple Account", - urlSubscription = "https://www.apple.com/account/subscriptions", + duration = ProSubscriptionDuration.ONE_MONTH, + providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_APP_STORE)!!, + quickRefundExpiry = Instant.now() + Duration.ofDays(7), + refundInProgress = false ) - ) - DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED -> SubscriptionType.Expired - }, - // SubscriptionType.NeverSubscribed, - refreshState = State.Success(Unit), - ) + DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED -> ProStatus.Expired( + expiredAt = Instant.now() - Duration.ofDays(14), + providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY)!! + ) + DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED_EARLIER -> ProStatus.Expired( + expiredAt = Instant.now() - Duration.ofDays(60), + providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY)!! + ) + DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED_APPLE -> ProStatus.Expired( + expiredAt = Instant.now() - Duration.ofDays(14), + providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_APP_STORE)!! + ) + }, + + refreshState = proDataRefreshState, + showProBadge = shouldShowProBadge, + ) + } - }.stateIn(GlobalScope, SharingStarted.Eagerly, + }.stateIn(scope, SharingStarted.Eagerly, initialValue = getDefaultSubscriptionStateData() ) @@ -112,21 +194,179 @@ class ProStatusManager @Inject constructor( val postProLaunchStatus: StateFlow = _postProLaunchStatus init { - GlobalScope.launch { + scope.launch { prefs.watchPostProStatus().collect { _postProLaunchStatus.update { isPostPro() } } } + + loginState.runWhileLoggedIn(scope) { + postProLaunchStatus + .collectLatest { postLaunch -> + if (postLaunch) { + RevocationListPollingWorker.schedule(application) + } else { + RevocationListPollingWorker.cancel(application) + } + } + } + + manageProDetailsRefreshScheduling() + manageCurrentProProofRevocation() + manageOtherPeoplePro() + } + + private fun manageOtherPeoplePro() { + loginState.runWhileLoggedIn(scope) { + postProLaunchStatus.collectLatest { postLaunch -> + if (postLaunch) { + merge( + configFactory.get().userConfigsChanged(EnumSet.of(UserConfigType.CONVO_INFO_VOLATILE)), + proDatabase.revocationChangeNotification, + ).onStart { emit(Unit) } + .collect { + // Go through all convo's pro proof and remove the ones that are revoked + val revokedConversations = configFactory.get() + .withUserConfigs { it.convoInfoVolatile.all() } + .asSequence() + .filterIsInstance() + .filter { convo -> + convo.proProofInfo?.genIndexHash?.let { proDatabase.isRevoked(it.data.toHexString()) } == true + } + .onEach { convo -> + convo.proProofInfo = null + } + .toList() + + if (revokedConversations.isNotEmpty()) { + Log.d( + DebugLogGroup.PRO_DATA.label, + "Clearing Pro proof info for ${revokedConversations.size} conversations due to revocation" + ) + + configFactory.get() + .withMutableUserConfigs { configs -> + for (convo in revokedConversations) { + configs.convoInfoVolatile.set(convo) + } + } + } + } + } + } + } + } + + private fun manageProDetailsRefreshScheduling() { + loginState.runWhileLoggedIn(scope) { + postProLaunchStatus + .collectLatest { postLaunch -> + if (postLaunch) { + merge( + configFactory.get() + .userConfigsChanged(EnumSet.of(UserConfigType.USER_PROFILE)) + .map { + configFactory.get().withUserConfigs { configs -> + configs.userProfile.getProAccessExpiryMs() + } + } + .distinctUntilChanged() + .map { "ProAccessExpiry in config changes" }, + + proDetailsRepository.loadState + .mapNotNull { it.lastUpdated?.first?.expiry } + .distinctUntilChanged() + .mapLatest { expiry -> + // Schedule a refresh 30seconds after access expiry + val refreshTime = expiry.plusSeconds(30) + + val now = snodeClock.currentTime() + if (now < refreshTime) { + val duration = Duration.between(now, refreshTime) + Log.d( + DebugLogGroup.PRO_SUBSCRIPTION.label, + "Delaying ProDetails refresh until $refreshTime due to access expiry" + ) + delay(duration) + } + + "ProDetails expiry reached" + }, + + configFactory.get() + .watchUserProConfig() + .filterNotNull() + .mapLatest { proConfig -> + val expiry = Instant.ofEpochMilli(proConfig.proProof.expiryMs) + // Schedule a refresh for a random number between 10 and 60 minutes before proof expiry + val now = snodeClock.currentTime() + + val refreshTime = + expiry.minus(Duration.ofMinutes((10..60).random().toLong())) + + if (now < refreshTime) { + Log.d( + DebugLogGroup.PRO_SUBSCRIPTION.label, + "Delaying ProDetails refresh until $refreshTime due to proof expiry" + ) + delay(Duration.between(now, expiry)) + } + }, + + flowOf("App starting up") + ).collect { refreshReason -> + Log.d( + DebugLogGroup.PRO_SUBSCRIPTION.label, + "Scheduling ProDetails fetch due to: $refreshReason" + ) + + proDetailsRepository.requestRefresh() + } + } else { + FetchProDetailsWorker.cancel(application) + } + } + } } - //todo PRO add "about to expire" CTA logic on app launch - //todo PRO add "expired" CTA logic on app launch + private fun manageCurrentProProofRevocation() { + loginState.runWhileLoggedIn(scope) { + postProLaunchStatus.collectLatest { postLaunch -> + if (postLaunch) { + combine( + configFactory.get() + .watchUserProConfig() + .mapNotNull { it?.proProof?.genIndexHashHex }, + + proDatabase.revocationChangeNotification + .onStart { emit(Unit) }, + + { proofGenIndexHash, _ -> + proofGenIndexHash.takeIf { proDatabase.isRevoked(it) } + } + ) + .filterNotNull() + .collectLatest { revokedHash -> + configFactory.get().withMutableUserConfigs { configs -> + if (configs.userProfile.getProConfig()?.proProof?.genIndexHashHex == revokedHash) { + Log.w( + DebugLogGroup.PRO_SUBSCRIPTION.label, + "Current Pro proof has been revoked, clearing Pro config" + ) + configs.userProfile.removeProConfig() + } + } + } + } + } + } + } /** * Logic to determine if we should animate the avatar for a user or freeze it on the first frame */ fun freezeFrameForUser(recipient: Recipient): Boolean{ - return if(!isPostPro() || recipient.isCommunityRecipient) false else !recipient.proStatus.isPro() + return if(!isPostPro() || recipient.isCommunityRecipient) false else !recipient.isPro } /** @@ -145,62 +385,123 @@ class ProStatusManager @Inject constructor( return prefs.forcePostPro() } - fun getCharacterLimit(status: ProStatus): Int { - return if (status.isPro()) MAX_CHARACTER_PRO else MAX_CHARACTER_REGULAR + fun getCharacterLimit(isPro: Boolean): Int { + return if (isPro) MAX_CHARACTER_PRO else MAX_CHARACTER_REGULAR } - fun getPinnedConversationLimit(status: ProStatus): Int { + fun getPinnedConversationLimit(isPro: Boolean): Int { if(!isPostPro()) return Int.MAX_VALUE // allow infinite pins while not in post Pro - return if (status.isPro()) Int.MAX_VALUE else MAX_PIN_REGULAR + return if (isPro) Int.MAX_VALUE else MAX_PIN_REGULAR } /** - * This will calculate the pro features of an outgoing message + * This will get the list of Pro features from an incoming message */ - fun calculateMessageProFeatures(status: ProStatus, message: String): List{ - val features = mutableListOf() - - // check for pro badge display - if (status.shouldShowProBadge()){ - features.add(MessageProFeature.ProBadge) - } - - // check for "long message" feature - if(message.length > MAX_CHARACTER_REGULAR){ - features.add(MessageProFeature.LongMessage) + fun getMessageProFeatures(message: MessageRecord): Set { + // use debug values if any + if(prefs.forceIncomingMessagesAsPro()){ + return prefs.getDebugMessageFeatures() } - // check is the user has an animated avatar - //todo PRO check for animated avatar here and add appropriate feature - - - return features + return message.proFeatures } /** - * This will get the list of Pro features from an incoming message + * To be called once a subscription has successfully gone through a provider. + * This will link that payment to our back end. */ - fun getMessageProFeatures(messageId: MessageId): Set{ - //todo PRO implement once we have data + @OptIn(ExperimentalCoroutinesApi::class) + suspend fun addProPayment(orderId: String, paymentId: String) { + // max 3 attempts as per PRD + val maxAttempts = 3 - // use debug values if any - if(prefs.forceIncomingMessagesAsPro()){ - return prefs.getDebugMessageFeatures() - } + // no point in going further if we have no key data + val keyData = loginState.loggedInState.value ?: throw Exception() + val rotatingKeyPair = ED25519.generate(null) - return emptySet() - } + for (attempt in 1..maxAttempts) { + try { + // 5s timeout as per PRD + val paymentResponse = withTimeout(5_000L) { + apiExecutor.executeRequest( + request = AddProPaymentRequest( + googlePaymentToken = paymentId, + googleOrderId = orderId, + masterPrivateKey = keyData.seeded.proMasterPrivateKey, + rotatingPrivateKey = rotatingKeyPair.secretKey.data + ) + ) + } + + when (paymentResponse) { + is ProApiResponse.Success -> { + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "Backend 'add pro payment' successful") + // Payment was successfully claimed - save it + configFactory.get().withMutableUserConfigs { configs -> + configs.userProfile.setProConfig( + ProConfig( + proProof = paymentResponse.data, + rotatingPrivateKey = rotatingKeyPair.secretKey.data + ) + ) + + configs.userProfile.setProBadge(true) + } + // refresh the pro details + proDetailsRepository.requestRefresh() + } + + is ProApiResponse.Failure -> { + // Handle payment failure + Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "Backend 'add pro payment' failure: $paymentResponse") + when (paymentResponse.status) { + // unknown payment is retryable - throw a generic exception here to go through our retries + AddPaymentErrorStatus.UnknownPayment -> { + throw Exception() + } + + // nothing to do if already redeemed + AddPaymentErrorStatus.AlreadyRedeemed -> { + return + } + + // non retryable error - throw our custom exception + AddPaymentErrorStatus.GenericError -> { + throw SubscriptionManager.PaymentServerException() + } + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: SubscriptionManager.PaymentServerException){ + // rethrow this error directly without retrying + Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "Backend 'add pro payment' PaymentServerException caught and rethrown") + throw e + }catch (e: Exception) { + Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "Backend 'add pro payment' exception", e) + // If not the last attempt, backoff a little and retry + if (attempt < maxAttempts) { + // small incremental backoff before retry + val backoffMs = 300L * attempt + delay(backoffMs) + } + } + } - enum class MessageProFeature { - ProBadge, LongMessage, AnimatedAvatar + // All attempts failed - throw our custom exception + Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "Backend 'add pro payment' - Al retries attempted, throwing our custom `PaymentServerException`") + throw SubscriptionManager.PaymentServerException() } companion object { const val MAX_CHARACTER_PRO = 10000 // max characters in a message for pro users private const val MAX_CHARACTER_REGULAR = 2000 // max characters in a message for non pro users - private const val MAX_PIN_REGULAR = 5 // max pinned conversation for non pro users + const val MAX_PIN_REGULAR = 5 // max pinned conversation for non pro users const val URL_PRO_SUPPORT = "https://getsession.org/pro-form" + const val DEFAULT_GOOGLE_STORE = "Google Play Store" + const val DEFAULT_APPLE_STORE = "Apple App Store" } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt new file mode 100644 index 0000000000..6c4b238fd6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt @@ -0,0 +1,88 @@ +package org.thoughtcrime.securesms.pro + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.await +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import org.session.libsession.snode.SnodeClock +import org.session.libsignal.exceptions.NonRetryableException +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.pro.api.GetProRevocationRequest +import org.thoughtcrime.securesms.pro.api.ProApiExecutor +import org.thoughtcrime.securesms.pro.api.successOrThrow +import org.thoughtcrime.securesms.pro.db.ProDatabase +import java.time.Duration +import java.util.concurrent.TimeUnit +import kotlin.coroutines.cancellation.CancellationException + +/** + * A long running worker which periodically polls the revocation list and updates the local database. + */ +@HiltWorker +class RevocationListPollingWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val proDatabase: ProDatabase, + private val getProRevocationRequestFactory: GetProRevocationRequest.Factory, + private val proApiExecutor: ProApiExecutor, + private val snodeClock: SnodeClock, +) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + try { + val lastTicket = proDatabase.getLastRevocationTicket() + val response = proApiExecutor.executeRequest(request = getProRevocationRequestFactory.create(lastTicket)).successOrThrow() + proDatabase.updateRevocations( + data = response.items, + newTicket = response.ticket + ) + + proDatabase.pruneRevocations(snodeClock.currentTime()) + + return Result.success() + } catch (e: Exception) { + if (e is CancellationException) { + throw e + } + + Log.e(TAG, "Error polling revocation list", e) + return if (e is NonRetryableException) { + Result.failure() + } else { + Result.retry() + } + } + } + + companion object { + private const val TAG = "RevocationListPollingWorker" + + private const val WORK_NAME = "RevocationListPollingWorker" + + suspend fun schedule(context: Context) { + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, + PeriodicWorkRequestBuilder(Duration.ofMinutes(15)) + .setInitialDelay(0L, TimeUnit.MILLISECONDS) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofSeconds(10)) + .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED)) + .build() + ) + .await() + } + + suspend fun cancel(context: Context) { + WorkManager.getInstance(context) + .cancelUniqueWork(WORK_NAME) + .await() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt deleted file mode 100644 index 8c8078c33f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.thoughtcrime.securesms.pro - -import org.session.libsession.utilities.recipients.ProStatus -import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration -import org.thoughtcrime.securesms.util.State - -sealed interface SubscriptionType{ - data object NeverSubscribed: SubscriptionType - - sealed interface Active: SubscriptionType{ - val proStatus: ProStatus.Pro - val duration: ProSubscriptionDuration - val nonOriginatingSubscription: NonOriginatingSubscription? // null if the current subscription is from the current platform - - data class AutoRenewing( - override val proStatus: ProStatus.Pro, - override val duration: ProSubscriptionDuration, - override val nonOriginatingSubscription: NonOriginatingSubscription? - ): Active - - data class Expiring( - override val proStatus: ProStatus.Pro, - override val duration: ProSubscriptionDuration, - override val nonOriginatingSubscription: NonOriginatingSubscription? - ): Active - - /** - * A structure representing a non-originating subscription - * For example if a user bought Pro on their iOS device through the Apple Store - * This will help us direct them to their original subscription platform if they want - * to update or cancel Pro - */ - data class NonOriginatingSubscription( - val device: String, - val store: String, - val platform: String, - val platformAccount: String, - val urlSubscription: String, - ) - } - - data object Expired: SubscriptionType -} - -data class SubscriptionState( - val type: SubscriptionType, - val refreshState: State, -) - -fun getDefaultSubscriptionStateData() = SubscriptionState( - type = SubscriptionType.NeverSubscribed, - refreshState = State.Loading -) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPayment.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPayment.kt new file mode 100644 index 0000000000..9918ee7f35 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPayment.kt @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.pro.api + +import kotlinx.serialization.DeserializationStrategy +import network.loki.messenger.libsession_util.pro.BackendRequests +import network.loki.messenger.libsession_util.pro.ProProof +import org.session.libsignal.utilities.Log + +class AddProPaymentRequest( + private val googlePaymentToken: String, + private val googleOrderId: String, + private val masterPrivateKey: ByteArray, + private val rotatingPrivateKey: ByteArray, +) : ApiRequest { + override val endpoint: String + get() = "add_pro_payment" + + override fun buildJsonBody(): String { + return BackendRequests.buildAddProPaymentRequestJson( + version = 0, + masterPrivateKey = masterPrivateKey, + rotatingPrivateKey = rotatingPrivateKey, + paymentProvider = BackendRequests.PAYMENT_PROVIDER_GOOGLE_PLAY, + paymentId = googlePaymentToken, + orderId = googleOrderId, + ) + } + + override fun convertErrorStatus(status: Int): AddPaymentErrorStatus { + Log.w("", "AddProPayment: convertErrorStatus: $status") + return AddPaymentErrorStatus.entries.firstOrNull { it.apiValue == status } + ?: AddPaymentErrorStatus.GenericError + } + + override val responseDeserializer: DeserializationStrategy + get() = ProProof.serializer() + +} + +enum class AddPaymentErrorStatus(val apiValue: Int) { + GenericError(1), + AlreadyRedeemed(100), + UnknownPayment(101), +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ApiRequest.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ApiRequest.kt new file mode 100644 index 0000000000..a6ffb030b6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ApiRequest.kt @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.pro.api + +import kotlinx.serialization.DeserializationStrategy + +/** + * Represents a generic API request to the Pro backend. + * + * @param ErrorStatus The type of error status returned by the API. + * @param Res The type of the expected response. + */ +interface ApiRequest { + /** + * The endpoint (path) for this API request, e.g. "v1/pro/payments" + */ + val endpoint: String + + val responseDeserializer: DeserializationStrategy + + fun convertErrorStatus(status: Int): ErrorStatus + + fun buildJsonBody(): String +} + + +/** + * Represents the response from a Pro API request. + * + * @param Res The type of the successful response data. + */ +sealed interface ProApiResponse { + data class Success(val data: T) : ProApiResponse + data class Failure(val status: S, val errors: List) : ProApiResponse +} + +fun ProApiResponse.successOrThrow(): T { + return when (this) { + is ProApiResponse.Success -> this.data + is ProApiResponse.Failure -> throw RuntimeException("Fail with status = $status, errors = $errors") + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProof.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProof.kt new file mode 100644 index 0000000000..bc0c2ae364 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProof.kt @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.pro.api + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.DeserializationStrategy +import network.loki.messenger.libsession_util.pro.BackendRequests +import network.loki.messenger.libsession_util.pro.ProProof +import org.session.libsession.snode.SnodeClock + +class GenerateProProofRequest @AssistedInject constructor( + @Assisted("master") private val masterPrivateKey: ByteArray, + @Assisted private val rotatingPrivateKey: ByteArray, + private val snodeClock: SnodeClock, +) : ApiRequest { + + override val endpoint: String + get() = "generate_pro_proof" + + override fun buildJsonBody(): String { + val now = snodeClock.currentTime() + return BackendRequests.buildGenerateProProofRequestJson( + version = 0, + masterPrivateKey = masterPrivateKey, + rotatingPrivateKey = rotatingPrivateKey, + nowMs = now.toEpochMilli(), + ) + } + + override val responseDeserializer: DeserializationStrategy + get() = ProProof.serializer() + + override fun convertErrorStatus(status: Int): GetProProofStatus = status + + @AssistedFactory + interface Factory { + fun create( + @Assisted("master") masterPrivateKey: ByteArray, + rotatingPrivateKey: ByteArray, + ): GenerateProProofRequest + } +} + +typealias GetProProofStatus = Int \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetails.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetails.kt new file mode 100644 index 0000000000..d8a117399c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetails.kt @@ -0,0 +1,138 @@ +package org.thoughtcrime.securesms.pro.api + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import network.loki.messenger.libsession_util.pro.BackendRequests +import network.loki.messenger.libsession_util.pro.PaymentProvider +import org.session.libsession.snode.SnodeClock +import org.session.libsession.utilities.serializable.InstantAsMillisSerializer +import java.time.Instant + +class GetProDetailsRequest @AssistedInject constructor( + private val snodeClock: SnodeClock, + @Assisted private val masterPrivateKey: ByteArray, +) : ApiRequest { + override val endpoint: String + get() = "get_pro_details" + + override fun buildJsonBody(): String { + return BackendRequests.buildGetProDetailsRequestJson( + version = 0, + proMasterPrivateKey = masterPrivateKey, + nowMs = snodeClock.currentTimeMills(), + count = 10, + ) + } + + override val responseDeserializer: DeserializationStrategy + get() = ProDetails.serializer() + + override fun convertErrorStatus(status: Int): Int = status + + @AssistedFactory + interface Factory { + fun create(masterPrivateKey: ByteArray): GetProDetailsRequest + } +} + +typealias ServerProDetailsStatus = Int +typealias ServerPlanDuration = Int + +@Serializable +class ProDetails( + val status: ServerProDetailsStatus, + + @SerialName("auto_renewing") + val autoRenewing: Boolean? = null, + + @SerialName("expiry_unix_ts_ms") + @Serializable(with = InstantAsMillisSerializer::class) + val expiry: Instant? = null, + + @SerialName("grace_period_duration_ms") + val graceDurationMs: Long? = null, + + @SerialName("error_report") + val errorReport: Int? = null, + + @SerialName("payments_total") + val paymentsTotal: Int? = null, + + @SerialName("items") + val paymentItems: List = emptyList(), + + @SerialName("refund_requested_unix_ts_ms") + val refundRequestedAtMs: Int = 0, + + + + val version: Int, +) { + init { + check((status != DETAILS_STATUS_ACTIVE && status != DETAILS_STATUS_EXPIRED) || expiry != null) { "Expiry must not be null for state other than 'never subscribed'" } + check((status != DETAILS_STATUS_ACTIVE && status != DETAILS_STATUS_EXPIRED) || paymentItems.isNotEmpty()) { "Can't have no payment items for state other than 'never subscribed'" } + } + + @Serializable + data class Item( + @SerialName("plan") + val planDuration: ServerPlanDuration, + + val status: Int, // Payment status [Redeemed, Revoked, Expired] - we do not use this status in the clients + + @SerialName("payment_provider") + val paymentProvider: PaymentProvider, + + @SerialName("expiry_unix_ts_ms") + @Serializable(with = InstantAsMillisSerializer::class) + val expiry: Instant? = null, + + @SerialName("grace_period_duration_ms") + val graceDurationMs: Long? = null, + + @SerialName("platform_refund_expiry_unix_ts_ms") + @Serializable(with = InstantAsMillisSerializer::class) + val platformExpiry: Instant? = null, + + @SerialName("redeemed_unix_ts_ms") + @Serializable(with = InstantAsMillisSerializer::class) + val timeRedeemed: Instant? = null, + + @SerialName("unredeemed_unix_ts_ms") + @Serializable(with = InstantAsMillisSerializer::class) + val timeUnredeemed: Instant? = null, + + @SerialName("revoked_unix_ts_ms") + @Serializable(with = InstantAsMillisSerializer::class) + val timeRevoked: Instant? = null, + + @SerialName("google_order_id") + val googleOrderId: String? = null, + + @SerialName("google_payment_token") + val googlePaymentToken: String? = null, + + @SerialName("apple_original_tx_id") + val appleOriginalTxId: String? = null, + + @SerialName("apple_tx_id") + val appleTxId: String? = null, + + @SerialName("apple_web_line_order_id") + val appleWebLineOrderId: String? = null, + ) + + companion object { + const val DETAILS_STATUS_NEVER_BEEN_PRO: ServerProDetailsStatus = 0 + const val DETAILS_STATUS_ACTIVE: ServerProDetailsStatus = 1 + const val DETAILS_STATUS_EXPIRED: ServerProDetailsStatus = 2 + + const val SERVER_PLAN_DURATION_1_MONTH: ServerPlanDuration = 1 + const val SERVER_PLAN_DURATION_3_MONTH: ServerPlanDuration = 2 + const val SERVER_PLAN_DURATION_12_MONTH: ServerPlanDuration = 3 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocations.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocations.kt new file mode 100644 index 0000000000..a800df5fd5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocations.kt @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.pro.api + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.session.libsession.utilities.serializable.InstantAsMillisSerializer +import java.time.Instant + +class GetProRevocationRequest @AssistedInject constructor( + @Assisted private val ticket: Long?, + private val json: Json, +) : ApiRequest { + override val responseDeserializer: DeserializationStrategy + get() = ProRevocations.serializer() + + override fun convertErrorStatus(status: Int): Int = status + + override val endpoint: String + get() = "get_pro_revocations" + + override fun buildJsonBody(): String { + return json.encodeToString( + mapOf( + "ticket" to (ticket ?: 0L), + "version" to 0 + ) + ) + } + + @AssistedFactory + interface Factory { + fun create(ticket: Long?): GetProRevocationRequest + } +} + +@Serializable +class ProRevocations( + val ticket: Long, + val items: List +) { + @Serializable + class Item( + @Serializable(with = InstantAsMillisSerializer::class) + @SerialName("expiry_unix_ts_ms") + val expiry: Instant, + + @SerialName("gen_index_hash") + val genIndexHash: String, + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApiExecutor.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApiExecutor.kt new file mode 100644 index 0000000000..c0560ac600 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApiExecutor.kt @@ -0,0 +1,88 @@ +package org.thoughtcrime.securesms.pro.api + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromStream +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.session.libsession.snode.OnionRequestAPI.sendOnionRequest +import org.session.libsession.snode.utilities.await +import javax.inject.Inject + +class ProApiExecutor @Inject constructor( + private val json: Json +) { + @Serializable + private data class RawProApiResponse( + val status: Int, + val result: JsonElement? = null, + val errors: List? = null, + ) { + fun toProApiResponse( + deserializer: DeserializationStrategy, + json: Json + ): ProApiResponse { + return if (status == 0) { + val data = json.decodeFromJsonElement(deserializer, requireNotNull(result) { + "Expected 'result' field to be present on successful response" + }) + ProApiResponse.Success(data) + } else { + ProApiResponse.Failure( + status = status, + errors = errors.orEmpty() + ) + } + } + } + + + /** + * Executes the given [ApiRequest] against the specified server using an onion request. + * + * @return A [ProApiResponse] containing either the successful response data or error information. + * Note that network errors, json deserialization will throw exceptions and are not represented + * in the [ProApiResponse]: you must catch and handle those separately. + */ + @OptIn(ExperimentalSerializationApi::class) + suspend fun executeRequest( + serverUrl: HttpUrl = "https://pro-backend-dev.getsession.org".toHttpUrl(), + serverX25519PubKeyHex: String = "920b81e9bf1a06e70814432668c61487d6fdbe13faaee3b09ebc56223061f140", + request: ApiRequest + ): ProApiResponse { + val rawResp = sendOnionRequest( + request = Request.Builder() + .url(serverUrl.resolve(request.endpoint)!!) + .post( + request.buildJsonBody().toRequestBody( + "application/json".toMediaType() + ) + ) + .build(), + server = serverUrl.host, + x25519PublicKey = serverX25519PubKeyHex + ).await().body!!.inputStream().use { + json.decodeFromStream(it) + } + + return if (rawResp.status == 0) { + val data = json.decodeFromJsonElement( + request.responseDeserializer, + requireNotNull(rawResp.result) { + "Expected 'result' field to be present on successful response" + }) + ProApiResponse.Success(data) + } else { + ProApiResponse.Failure( + status = request.convertErrorStatus(rawResp.status), + errors = rawResp.errors.orEmpty() + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt new file mode 100644 index 0000000000..ef517ab69d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt @@ -0,0 +1,211 @@ +package org.thoughtcrime.securesms.pro.db + +import android.content.Context +import androidx.collection.LruCache +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.transaction +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.serialization.json.Json +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.Database +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.pro.api.ProDetails +import org.thoughtcrime.securesms.pro.api.ProRevocations +import org.thoughtcrime.securesms.util.asSequence +import java.time.Instant +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +class ProDatabase @Inject constructor( + @ApplicationContext context: Context, + databaseHelper: Provider, + private val json: Json, +) : Database(context, databaseHelper) { + + private val cache = LruCache(1000) + + private val mutableRevocationChangeNotification = MutableSharedFlow( + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + val revocationChangeNotification: SharedFlow get() = mutableRevocationChangeNotification + fun getLastRevocationTicket(): Long? { + val cursor = readableDatabase.query("SELECT CAST(value AS INTEGER) FROM pro_state WHERE name = '$STATE_NAME_LAST_TICKET'") + return cursor.use { + if (it.moveToFirst()) { + it.getLong(0) + } else { + null + } + } + } + + fun updateRevocations( + newTicket: Long, + data: List + ) { + var changes = 0 + + writableDatabase.transaction { + if (data.isNotEmpty()) { + //language=roomsql + compileStatement( + """ + INSERT INTO pro_revocations (gen_index_hash, expiry_ms) + VALUES (?, ?) + ON CONFLICT DO UPDATE SET expiry_ms=excluded.expiry_ms WHERE expiry_ms != excluded.expiry_ms + """ + ).use { stmt -> + for (item in data) { + stmt.bindString(1, item.genIndexHash) + stmt.bindLong(2, item.expiry.toEpochMilli()) + changes += stmt.executeUpdateDelete() + stmt.clearBindings() + } + } + } + + //language=roomsql + compileStatement(""" + INSERT OR REPLACE INTO pro_state (name, value) + VALUES (?, ?) + """).use { stmt -> + stmt.bindString(1, STATE_NAME_LAST_TICKET) + stmt.bindLong(2, newTicket) + } + } + + for (item in data) { + cache.put(item.genIndexHash, Unit) + } + + if (changes > 0) { + mutableRevocationChangeNotification.tryEmit(Unit) + } + } + + fun pruneRevocations(now: Instant) { + //language=roomsql + val pruned = writableDatabase.rawQuery(""" + DELETE FROM pro_revocations + WHERE expiry_ms < ? + RETURNING gen_index_hash + """, now.toEpochMilli()).use { cursor -> + cursor.asSequence() + .map { it.getString(0) } + .toList() + } + + for (genIndexHash in pruned) { + cache.remove(genIndexHash) + } + + Log.d(TAG, "Pruned ${pruned.size} expired pro revocations") + } + + fun isRevoked(genIndexHash: String): Boolean { + if (cache[genIndexHash] != null) { + return true + } + + //language=roomsql + readableDatabase.query(""" + SELECT 1 FROM pro_revocations + WHERE gen_index_hash = ? + LIMIT 1 + """, arrayOf(genIndexHash)).use { cursor -> + if (cursor.moveToFirst()) { + cache.put(genIndexHash, Unit) + return true + } + return false + } + } + + private val mutableProDetailsChangeNotification = MutableSharedFlow( + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + val proDetailsChangeNotification: SharedFlow get() = mutableProDetailsChangeNotification + + fun getProDetailsAndLastUpdated(): Pair? { + return readableDatabase.rawQuery(""" + SELECT name, value FROM pro_state + WHERE name IN (?, ?) + """, STATE_PRO_DETAILS, STATE_PRO_DETAILS_UPDATED_AT).use { cursor -> + var details: ProDetails? = null + var updatedAt: Instant? = null + + while (cursor.moveToNext()) { + when (val name = cursor.getString(0)) { + STATE_PRO_DETAILS -> details = json.decodeFromString(cursor.getString(1)) + STATE_PRO_DETAILS_UPDATED_AT -> updatedAt = Instant.ofEpochMilli(cursor.getString(1).toLong()) + else -> error("Unexpected state name $name") + } + } + + if (details != null && updatedAt != null) { + details to updatedAt + } else { + null + } + } + } + + fun updateProDetails(proDetails: ProDetails, updatedAt: Instant) { + val changes = writableDatabase.compileStatement(""" + INSERT INTO pro_state (name, value) + VALUES (?, ?), (?, ?) + ON CONFLICT DO UPDATE SET value=excluded.value + WHERE value != excluded.value + """).use { stmt -> + stmt.bindString(1, STATE_PRO_DETAILS) + stmt.bindString(2, json.encodeToString(proDetails)) + stmt.bindString(3, STATE_PRO_DETAILS_UPDATED_AT) + stmt.bindString(4, updatedAt.toEpochMilli().toString()) + stmt.executeUpdateDelete() + } + + if (changes > 0) { + mutableProDetailsChangeNotification.tryEmit(Unit) + } + } + + + companion object { + private const val TAG = "ProRevocationDatabase" + + private const val STATE_NAME_LAST_TICKET = "last_ticket" + + + private const val STATE_PRO_DETAILS = "pro_details" + private const val STATE_PRO_DETAILS_UPDATED_AT = "pro_details_updated_at" + + private const val ROTATING_KEY_VALIDITY_DAYS = 15 + + fun createTable(db: SupportSQLiteDatabase) { + // A table to hold the list of pro revocations + db.execSQL(""" + CREATE TABLE pro_revocations( + gen_index_hash TEXT NOT NULL PRIMARY KEY, + expiry_ms INTEGER NOT NULL + ) WITHOUT ROWID + """) + + // A table to hold state related to pro + db.execSQL(""" + CREATE TABLE pro_state( + name TEXT NOT NULL PRIMARY KEY, + value TEXT + ) WITHOUT ROWID""" + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index 566e48365b..7c91139d3f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -1,19 +1,41 @@ package org.thoughtcrime.securesms.pro.subscription +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager.PurchaseEvent import javax.inject.Inject +import javax.inject.Singleton /** * An implementation representing a lack of support for subscription */ -class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { +@Singleton +class NoOpSubscriptionManager @Inject constructor( + proStatusManager: ProStatusManager, + @param:ManagerScope scope: CoroutineScope, +) : SubscriptionManager(proStatusManager, scope) { override val id = "noop" - override val displayName = "" + override val name = "" override val description = "" override val iconRes = null - override fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) {} + override val supportsBilling = MutableStateFlow(false) + + override suspend fun purchasePlan(subscriptionDuration: ProSubscriptionDuration): Result { + return Result.success(Unit) + } override val availablePlans: List get() = emptyList() - //todo PRO test out build type with no subscription providers available - What do we show on the Pro Settings page? + override suspend fun hasValidSubscription(): Boolean { + return false + } + + override suspend fun getSubscriptionPrices(): List { + return emptyList() + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt index 5f1b7b1ace..e07be952ac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt @@ -5,12 +5,15 @@ import java.time.Duration import java.time.Period import java.time.ZonedDateTime -enum class ProSubscriptionDuration(val duration: Period) { - ONE_MONTH(Period.ofMonths(1)), - THREE_MONTHS(Period.ofMonths(3)), - TWELVE_MONTHS(Period.ofMonths(12)) +enum class ProSubscriptionDuration(val duration: Period, val id: String) { + ONE_MONTH(Period.ofMonths(1), "session-pro-1-month"), + THREE_MONTHS(Period.ofMonths(3), "session-pro-3-months"), + TWELVE_MONTHS(Period.ofMonths(12), "session-pro-12-months") } +fun ProSubscriptionDuration.getById(id: String): ProSubscriptionDuration? = + ProSubscriptionDuration.entries.find { it.id == id } + private val proSettingsDateFormat = "MMMM d, yyyy" fun ProSubscriptionDuration.expiryFromNow(): String { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionCoordinator.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionCoordinator.kt index 3f2816d01b..64c8fb23d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionCoordinator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionCoordinator.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.pro.subscription import jakarta.inject.Inject +import jakarta.inject.Singleton import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent @@ -8,8 +9,10 @@ import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent /** * Helper class to handle the selection and management of our available subscription providers */ +@Singleton class SubscriptionCoordinator @Inject constructor( private val availableManagers: Set<@JvmSuppressWildcards SubscriptionManager>, + private val noopSubManager: NoOpSubscriptionManager, private val prefs: TextSecurePreferences ): OnAppStartupComponent { @@ -20,7 +23,7 @@ class SubscriptionCoordinator @Inject constructor( when { managers.isEmpty() -> { - currentManager = NoOpSubscriptionManager() + currentManager = noopSubManager } managers.size == 1 -> { currentManager = managers.first() diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index ee22e8d082..1e26ff0f88 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -1,17 +1,98 @@ package org.thoughtcrime.securesms.pro.subscription +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import org.thoughtcrime.securesms.pro.ProStatusManager /** * Represents the implementation details of a given subscription provider */ -interface SubscriptionManager: OnAppStartupComponent { - val id: String - val displayName: String - val description: String - val iconRes: Int? +abstract class SubscriptionManager( + protected val proStatusManager: ProStatusManager, + @param:ManagerScope protected val scope: CoroutineScope, +): OnAppStartupComponent { + abstract val id: String + abstract val name: String + abstract val description: String + abstract val iconRes: Int? - val availablePlans: List + abstract val supportsBilling: StateFlow + + abstract val availablePlans: List + + sealed interface PurchaseEvent { + data object Success : PurchaseEvent + data object Cancelled : PurchaseEvent + sealed interface Failed : PurchaseEvent { + data class GenericError(val errorMessage: String? = null): Failed + data class ServerError(val orderId: String, val paymentId: String) : Failed + } + } + + // purchase events + protected val _purchaseEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val purchaseEvents: SharedFlow = _purchaseEvents.asSharedFlow() + + abstract suspend fun purchasePlan(subscriptionDuration: ProSubscriptionDuration): Result + + + /** + * Checks whether there is a valid subscription for the current user within this subscriber's billing API + */ + abstract suspend fun hasValidSubscription(): Boolean + + /** + * Gets a list of pricing for the subscriptions + * @throws Exception in case of errors fetching prices + */ + @Throws(Exception::class) + abstract suspend fun getSubscriptionPrices(): List + + /** + * Function called when a purchased has been made successfully from the subscription api + */ + fun onPurchaseSuccessful(orderId: String, paymentId: String){ + // we need to tie our purchase with the back end + scope.launch { + try { + proStatusManager.addProPayment(orderId, paymentId) + _purchaseEvents.emit(PurchaseEvent.Success) + } catch (e: Exception) { + when (e) { + is PaymentServerException -> { + _purchaseEvents.emit( + PurchaseEvent.Failed.ServerError( + orderId = orderId, + paymentId = paymentId + ) + ) + } + else -> _purchaseEvents.emit(PurchaseEvent.Failed.GenericError()) + } + } + } + } + + class PaymentServerException: Exception() + + data class SubscriptionPricing( + val subscriptionDuration: ProSubscriptionDuration, + val priceAmountMicros: Long, + val priceCurrencyCode: String, + val billingPeriodIso: String, + val formattedTotal: String, + ) +} - fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/EmojiCount.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/EmojiCount.java index 46030f7504..566ed85f8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/EmojiCount.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/EmojiCount.java @@ -6,23 +6,24 @@ import java.util.List; -final class EmojiCount { +import kotlin.collections.CollectionsKt; - static EmojiCount all(@NonNull List reactions) { - return new EmojiCount("", "", reactions); - } +public final class EmojiCount { private final String baseEmoji; private final String displayEmoji; private final List reactions; + private final boolean shouldAccumulateReactionCount; EmojiCount(@NonNull String baseEmoji, @NonNull String emoji, - @NonNull List reactions) + @NonNull List reactions, + boolean shouldAccumulateReactionCount) { this.baseEmoji = baseEmoji; this.displayEmoji = emoji; this.reactions = reactions; + this.shouldAccumulateReactionCount = shouldAccumulateReactionCount; } public @NonNull String getBaseEmoji() { @@ -34,7 +35,12 @@ static EmojiCount all(@NonNull List reactions) { } public int getCount() { - return Stream.of(reactions).reduce(0, (count, reaction) -> count + reaction.getCount()); + if (shouldAccumulateReactionCount) { + return CollectionsKt.fold(reactions, 0, (count, reaction) -> count + reaction.getCount()); + } + + ReactionDetails first = CollectionsKt.getOrNull(reactions, 0); + return first == null ? 0 : first.getCount(); } public @NonNull List getReactions() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsDialogFragment.java index 4fc6fdbe98..a8f4106bbb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsDialogFragment.java @@ -43,6 +43,7 @@ public final class ReactionsDialogFragment extends BottomSheetDialogFragment imp private static final String ARGS_IS_MODERATOR = "reactions.args.is.moderator"; private static final String ARGS_EMOJI = "reactions.args.emoji"; private static final String ARGS_CAN_REMOVE = "reactions.args.can.remove"; + private static final String ARGS_FROM_COMMUNITY_THREAD = "reactions.args.from.community.thread"; private ViewPager2 recipientPagerView; private ReactionViewPagerAdapter recipientsAdapter; @@ -53,7 +54,11 @@ public final class ReactionsDialogFragment extends BottomSheetDialogFragment imp private final LifecycleDisposable disposables = new LifecycleDisposable(); - public static DialogFragment create(MessageId messageId, boolean isUserModerator, @Nullable String emoji, boolean canRemove) { + public static DialogFragment create(MessageId messageId, + boolean isUserModerator, + @Nullable String emoji, + boolean canRemove, + boolean fromCommunityThread) { Bundle args = new Bundle(); DialogFragment fragment = new ReactionsDialogFragment(); @@ -62,6 +67,7 @@ public static DialogFragment create(MessageId messageId, boolean isUserModerator args.putBoolean(ARGS_IS_MODERATOR, isUserModerator); args.putString(ARGS_EMOJI, emoji); args.putBoolean(ARGS_CAN_REMOVE, canRemove); + args.putBoolean(ARGS_FROM_COMMUNITY_THREAD, fromCommunityThread); fragment.setArguments(args); @@ -158,7 +164,8 @@ private void setUpViewModel(@NonNull MessageId messageId) { getDefaultViewModelProviderFactory(), HiltViewModelExtensions.withCreationCallback( getDefaultViewModelCreationExtras(), - (ReactionsViewModel.Factory factory) -> factory.create(messageId) + (ReactionsViewModel.Factory factory) -> factory.create( + messageId, requireArguments().getBoolean(ARGS_FROM_COMMUNITY_THREAD)) ) ).get(ReactionsViewModel.class); diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsViewModel.java index 0c5c601f77..9d85a0f2bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsViewModel.java @@ -25,11 +25,15 @@ public class ReactionsViewModel extends ViewModel { private final MessageId messageId; private final ReactionsRepository repository; + private final boolean fromCommunityThread; @AssistedInject - public ReactionsViewModel(@Assisted @NonNull MessageId messageId, final ReactionsRepository repository) { + public ReactionsViewModel(@Assisted @NonNull MessageId messageId, + @Assisted boolean fromCommunityThread, + final ReactionsRepository repository) { this.messageId = messageId; this.repository = repository; + this.fromCommunityThread = fromCommunityThread; } public @NonNull @@ -38,9 +42,11 @@ Observable> getEmojiCounts() { .map(reactionList -> Stream.of(reactionList) .groupBy(ReactionDetails::getBaseEmoji) .sorted(this::compareReactions) - .map(entry -> new EmojiCount(entry.getKey(), - getCountDisplayEmoji(entry.getValue()), - entry.getValue())) + .map(entry -> new EmojiCount( + entry.getKey(), + getCountDisplayEmoji(entry.getValue()), + entry.getValue(), + !fromCommunityThread)) .toList()) .observeOn(AndroidSchedulers.mainThread()); } @@ -75,6 +81,6 @@ private long getLatestTimestamp(List reactions) { @AssistedFactory public interface Factory { - ReactionsViewModel create(@NonNull MessageId messageId); + ReactionsViewModel create(@NonNull MessageId messageId, boolean fromCommunityThread); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordViewModel.kt index 98a9a9a56f..783199df9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordViewModel.kt @@ -7,27 +7,30 @@ import android.content.Context import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch 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.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.crypto.MnemonicUtilities +import javax.inject.Inject @HiltViewModel class RecoveryPasswordViewModel @Inject constructor( private val application: Application, private val prefs: TextSecurePreferences, + private val loginStateRepository: LoginStateRepository, ): AndroidViewModel(application) { - val seed = MutableStateFlow(null) + val seed: StateFlow = loginStateRepository + .loggedInState + .map { it?.seeded?.seed?.data?.toHexString() } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + val mnemonic = seed.filterNotNull() .map { MnemonicCodec { @@ -52,13 +55,6 @@ class RecoveryPasswordViewModel @Inject constructor( ClipData.newPlainText("Seed", normalisedMnemonic) .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 56b8d0bef7..a78897ac18 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -9,8 +9,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge @@ -32,8 +30,8 @@ import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeClock -import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.isGroupV2 @@ -45,6 +43,7 @@ import org.session.libsession.utilities.upsertContact import org.session.libsession.utilities.userConfigsChanged import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.CommunityDatabase import org.thoughtcrime.securesms.database.DraftDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase @@ -129,7 +128,6 @@ interface ConversationRepository { @Singleton class DefaultConversationRepository @Inject constructor( - private val textSecurePreferences: TextSecurePreferences, private val messageDataProvider: MessageDataProvider, private val threadDb: ThreadDatabase, private val communityDatabase: CommunityDatabase, @@ -144,26 +142,27 @@ class DefaultConversationRepository @Inject constructor( private val recipientDatabase: RecipientSettingsDatabase, private val recipientRepository: RecipientRepository, @param:ManagerScope private val scope: CoroutineScope, + private val messageSender: MessageSender, + private val loginStateRepository: LoginStateRepository, ) : ConversationRepository { - override val conversationListAddressesFlow = configFactory - .userConfigsChanged(EnumSet.of( - UserConfigType.CONTACTS, - UserConfigType.USER_PROFILE, - UserConfigType.USER_GROUPS - )) - .castAwayType() - .onStart { - // Only start when we have a local number - textSecurePreferences.watchLocalNumber().filterNotNull().first() - - emit(Unit) - } - .map { getConversationListAddresses() } - .stateIn(scope, SharingStarted.Eagerly, getConversationListAddresses()) + override val conversationListAddressesFlow = loginStateRepository.flowWithLoggedInState { + configFactory + .userConfigsChanged(EnumSet.of( + UserConfigType.CONTACTS, + UserConfigType.USER_PROFILE, + UserConfigType.USER_GROUPS + )) + .castAwayType() + .onStart { + emit(Unit) + } + .map { getConversationListAddresses() } + }.stateIn(scope, SharingStarted.Eagerly, getConversationListAddresses()) - private fun getConversationListAddresses() = buildSet { - val myAddress = Address.Standard(AccountId(textSecurePreferences.getLocalNumber() ?: return@buildSet )) + private fun getConversationListAddresses() = buildSet { + val myAddress = loginStateRepository.getLocalNumber()?.toAddress() as? Address.Standard + ?: return@buildSet // Always have NTS - we should only "hide" them on home screen - the convo should never be deleted add(myAddress) @@ -275,17 +274,17 @@ class DefaultConversationRepository @Inject constructor( val outgoingTextMessage = OutgoingTextMessage.fromOpenGroupInvitation( openGroupInvitation, contact, - message.sentTimestamp, + message.sentTimestamp!!, expirationConfig.expiryMillis, expireStartedAt - ) + )!! message.id = MessageId( smsDb.insertMessageOutbox(contactThreadId, outgoingTextMessage, false, message.sentTimestamp!!, true), false ) - MessageSender.send(message, contact) + messageSender.send(message, contact) } } @@ -396,10 +395,10 @@ class DefaultConversationRepository @Inject constructor( messages: Set ) { // delete the messages remotely - val userAddress: Address? = textSecurePreferences.getLocalNumber()?.let { Address.fromSerialized(it) } val userAuth = requireNotNull(storage.userAuth) { "User auth is required to delete messages remotely" } + val userAddress = userAuth.accountId.toAddress() messages.forEach { message -> // delete from swarm @@ -410,12 +409,12 @@ class DefaultConversationRepository @Inject constructor( // send an UnsendRequest to user's swarm buildUnsendRequest(message).let { unsendRequest -> - userAddress?.let { MessageSender.send(unsendRequest, it) } + messageSender.send(unsendRequest, userAddress) } // send an UnsendRequest to recipient's swarm buildUnsendRequest(message).let { unsendRequest -> - MessageSender.send(unsendRequest, recipient) + messageSender.send(unsendRequest, recipient) } } } @@ -428,7 +427,7 @@ class DefaultConversationRepository @Inject constructor( messages.forEach { message -> // send an UnsendRequest to group's swarm buildUnsendRequest(message).let { unsendRequest -> - MessageSender.send(unsendRequest, recipient) + messageSender.send(unsendRequest, recipient) } } } @@ -453,10 +452,10 @@ class DefaultConversationRepository @Inject constructor( messages: Set ) { // delete the messages remotely - val userAddress: Address? = textSecurePreferences.getLocalNumber()?.let { Address.fromSerialized(it) } val userAuth = requireNotNull(storage.userAuth) { "User auth is required to delete messages remotely" } + val userAddress = userAuth.accountId.toAddress() messages.forEach { message -> // delete from swarm @@ -467,14 +466,15 @@ class DefaultConversationRepository @Inject constructor( // send an UnsendRequest to user's swarm buildUnsendRequest(message).let { unsendRequest -> - userAddress?.let { MessageSender.send(unsendRequest, it) } + messageSender.send(unsendRequest, userAddress) } } } private fun buildUnsendRequest(message: MessageRecord): UnsendRequest { return UnsendRequest( - author = message.takeUnless { it.isOutgoing }?.run { individualRecipient.address.address } ?: textSecurePreferences.getLocalNumber(), + author = message.takeUnless { it.isOutgoing }?.run { individualRecipient.address.address } + ?: loginStateRepository.requireLocalNumber(), timestamp = message.timestamp ) } @@ -551,7 +551,7 @@ class DefaultConversationRepository @Inject constructor( } withContext(Dispatchers.Default) { - MessageSender.send(message = MessageRequestResponse(true), address = recipient) + messageSender.send(message = MessageRequestResponse(true), address = recipient) // add a control message for our user storage.insertMessageRequestResponseFromYou(threadDb.getOrCreateThreadIdFor(recipient)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/reviews/ui/InAppReviewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/reviews/ui/InAppReviewViewModel.kt index 90e0482a27..a57cfcd149 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reviews/ui/InAppReviewViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/reviews/ui/InAppReviewViewModel.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.stateIn +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.reviews.InAppReviewManager import org.thoughtcrime.securesms.reviews.StoreReviewManager @@ -25,6 +26,7 @@ private const val TAG = "InAppReviewViewModel" class InAppReviewViewModel @Inject constructor( private val manager: InAppReviewManager, private val storeReviewManager: StoreReviewManager, + private val prefs: TextSecurePreferences, ) : ViewModel() { private val commands = MutableSharedFlow(extraBufferCapacity = 1) @@ -46,7 +48,13 @@ class InAppReviewViewModel @Inject constructor( UiState.StartPrompt -> { when (event) { - UiCommand.PositiveButtonClicked -> UiState.PositivePrompt + // "It's Great" button clicked + UiCommand.PositiveButtonClicked -> { + // mark the app as needing to display the donation post positive review + prefs.setShowDonationCTAFromPositiveReview(true) + + UiState.PositivePrompt + } UiCommand.NegativeButtonClicked -> UiState.NegativePrompt UiCommand.CloseButtonClicked -> { manager.onEvent(InAppReviewManager.Event.Dismiss) diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt index 5f21b6e11b..02e744038c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt @@ -12,22 +12,14 @@ import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.signal.IncomingMediaMessage -import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage -import org.session.libsession.messaging.messages.signal.OutgoingSecureMediaMessage +import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.snode.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.toAddress -import org.session.libsession.utilities.DistributionTypes -import org.session.libsession.utilities.GroupUtil -import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.messages.SignalServiceGroup -import org.session.libsignal.utilities.Hex -import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.MessagingDatabase import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.RecipientRepository @@ -61,7 +53,7 @@ class ExpiringMessageManager @Inject constructor( private val mmsDatabase: MmsDatabase, private val clock: SnodeClock, private val storage: Lazy, - private val preferences: TextSecurePreferences, + private val loginStateRepository: LoginStateRepository, private val recipientRepository: RecipientRepository, private val threadDatabase: ThreadDatabase, @ManagerScope scope: CoroutineScope, @@ -85,7 +77,6 @@ class ExpiringMessageManager @Inject constructor( val sentTimestamp = message.sentTimestamp val groupAddress = message.groupPublicKey?.toAddress() as? Address.GroupLike val expiresInMillis = message.expiryMode.expiryMillis - var groupInfo = Optional.absent() val address = fromSerialized(senderPublicKey!!) var recipient = recipientRepository.getRecipientSync(address) @@ -98,20 +89,21 @@ class ExpiringMessageManager @Inject constructor( val threadId = recipient.address.let(storage.get()::getThreadId) ?: return null val mediaMessage = IncomingMediaMessage( - address, sentTimestamp!!, -1, - expiresInMillis, - 0, // Marking expiryStartedAt as 0 as expiration logic will be universally applied on received messages + from = address, + sentTimeMillis = sentTimestamp!!, + expiresIn = expiresInMillis, + expireStartedAt = 0, // Marking expiryStartedAt as 0 as expiration logic will be universally applied on received messages // We no longer set this to true anymore as it won't be used in the future, - false, - false, - Optional.absent(), - Optional.fromNullable(groupAddress), - Optional.absent(), - DisappearingMessageUpdate(message.expiryMode), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent() + isMessageRequestResponse = false, + hasMention = false, + body = null, + group = groupAddress, + attachments = emptyList(), + proFeatures = emptySet(), + messageContent = DisappearingMessageUpdate(message.expiryMode), + quote = null, + linkPreviews = emptyList(), + dataExtractionNotification = null ) //insert the timer update message mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true) @@ -130,43 +122,37 @@ class ExpiringMessageManager @Inject constructor( message: ExpirationTimerUpdate, ): MessageId? { val sentTimestamp = message.sentTimestamp - val groupId = message.groupPublicKey + val groupId = message.groupPublicKey?.toAddress() as? Address.GroupLike val duration = message.expiryMode.expiryMillis try { - val serializedAddress = when { - groupId == null -> message.syncTarget ?: message.recipient!! - groupId.startsWith(IdPrefix.GROUP.value) -> groupId - else -> doubleEncodeGroupID(groupId) - } - val address = fromSerialized(serializedAddress) + val serializedAddress = groupId ?: (message.syncTarget ?: message.recipient!!).toAddress() - message.threadID = storage.get().getOrCreateThreadIdFor(address) + message.threadID = storage.get().getOrCreateThreadIdFor(serializedAddress) val content = DisappearingMessageUpdate(message.expiryMode) - val timerUpdateMessage = if (groupId != null) OutgoingGroupMediaMessage( - address, - "", - groupId, - null, - sentTimestamp!!, - duration, - 0, // Marking as 0 as expiration shouldn't start until we send the message - false, - null, - emptyList(), - emptyList(), - content - ) else OutgoingSecureMediaMessage( - address, - "", - emptyList(), - sentTimestamp!!, - DistributionTypes.CONVERSATION, - duration, - 0, // Marking as 0 as expiration shouldn't start until we send the message - null, - emptyList(), - emptyList(), - content + val timerUpdateMessage = if (groupId != null) OutgoingMediaMessage( + recipient = serializedAddress, + body = "", + group = groupId, + avatar = null, + sentTimeMillis = sentTimestamp!!, + expiresInMillis = duration, + expireStartedAtMillis = 0, // Marking as 0 as expiration shouldn't start until we send the message + isGroupUpdateMessage = false, + quote = null, + previews = emptyList(), + messageContent = content + ) else OutgoingMediaMessage( + recipient = serializedAddress, + body = "", + attachments = emptyList(), + sentTimeMillis = sentTimestamp!!, + expiresInMillis = duration, + expireStartedAtMillis = 0, // Marking as 0 as expiration shouldn't start until we send the message + outgoingQuote = null, + messageContent = content, + linkPreviews = emptyList(), + group = null, + isGroupUpdateMessage = false ) return mmsDatabase.insertSecureDecryptedMessageOutbox( @@ -185,7 +171,7 @@ class ExpiringMessageManager @Inject constructor( } override fun insertExpirationTimerMessage(message: ExpirationTimerUpdate) { - val userPublicKey = preferences.getLocalNumber() + val userPublicKey = loginStateRepository.requireLocalNumber() val senderPublicKey = message.sender message.id = if (senderPublicKey == null || userPublicKey == senderPublicKey) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/TypingStatusRepository.java b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/TypingStatusRepository.java index a3b4784b83..667453d09b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/TypingStatusRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/TypingStatusRepository.java @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.sskenvironment; import android.annotation.SuppressLint; -import android.content.Context; import androidx.annotation.NonNull; import androidx.lifecycle.LiveData; @@ -13,9 +12,9 @@ import org.jetbrains.annotations.NotNull; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.SSKEnvironment; -import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.Util; import org.session.libsignal.utilities.Log; +import org.thoughtcrime.securesms.auth.LoginStateRepository; import org.thoughtcrime.securesms.database.RecipientRepository; import java.util.ArrayList; @@ -30,8 +29,6 @@ import javax.inject.Inject; import javax.inject.Singleton; -import dagger.hilt.android.qualifiers.ApplicationContext; - @SuppressLint("UseSparseArrays") @Singleton public class TypingStatusRepository implements SSKEnvironment.TypingIndicatorsProtocol { @@ -44,27 +41,24 @@ public class TypingStatusRepository implements SSKEnvironment.TypingIndicatorsPr private final Map timers; private final Map> notifiers; private final MutableLiveData> threadsNotifier; - private final TextSecurePreferences preferences; private final RecipientRepository recipientRepository; - private final Context appContext; + private final LoginStateRepository loginStateRepository; @Inject public TypingStatusRepository( - @ApplicationContext Context appContext, - TextSecurePreferences preferences, - RecipientRepository recipientRepository) { + RecipientRepository recipientRepository, + LoginStateRepository loginStateRepository){ this.recipientRepository = recipientRepository; this.typistMap = new HashMap<>(); this.timers = new HashMap<>(); this.notifiers = new HashMap<>(); this.threadsNotifier = new MutableLiveData<>(); - this.preferences = preferences; - this.appContext = appContext; + this.loginStateRepository = loginStateRepository; } @Override public synchronized void didReceiveTypingStartedMessage(long threadId, @NotNull Address author, int device) { - if (author.toString().equals(preferences.getLocalNumber())) { + if (author.toString().equals(loginStateRepository.getLocalNumber())) { return; } @@ -93,7 +87,7 @@ public synchronized void didReceiveTypingStartedMessage(long threadId, @NotNull @Override public synchronized void didReceiveTypingStoppedMessage(long threadId, @NotNull Address author, int device, boolean isReplacedByIncomingMessage) { - if (author.toString().equals(preferences.getLocalNumber())) { + if (author.toString().equals(loginStateRepository.getLocalNumber())) { return; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDataManager.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDataManager.kt index 05f2c0115d..b86b5ad3b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDataManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDataManager.kt @@ -9,8 +9,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import javax.inject.Inject @@ -18,7 +18,7 @@ import javax.inject.Singleton @Singleton class TokenDataManager @Inject constructor( - private val textSecurePreferences: TextSecurePreferences, + private val loginStateRepository: LoginStateRepository, private val tokenRepository: TokenRepository, @param:ManagerScope private val scope: CoroutineScope ) : OnAppStartupComponent { @@ -40,7 +40,7 @@ class TokenDataManager @Inject constructor( override fun onPostAppStarted() { // we want to preload the data as soon as the user is logged in scope.launch { - textSecurePreferences.watchLocalNumber() + loginStateRepository.loggedInState .map { it != null } .distinctUntilChanged() .collect { logged -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt index a085d7d991..b8e9b44582 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt @@ -11,14 +11,12 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN import nl.komponents.kovenant.Promise import org.session.libsession.LocalisedTimeUtil.toShortSinglePartString import org.session.libsession.snode.OnionRequestAPI @@ -29,10 +27,10 @@ import org.session.libsession.utilities.NonTranslatableStringConstants.TOKEN_NAM import org.session.libsession.utilities.NonTranslatableStringConstants.USD_NAME_SHORT import org.session.libsession.utilities.StringSubstitutionConstants.DATE_TIME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.RELATIVE_TIME_KEY -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.NetworkConnectivity @@ -40,7 +38,6 @@ import org.thoughtcrime.securesms.util.NumberUtil.formatAbbreviated import org.thoughtcrime.securesms.util.NumberUtil.formatWithDecimalPlaces import javax.inject.Inject import kotlin.math.min -import kotlin.sequences.filter import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes @@ -49,7 +46,7 @@ class TokenPageViewModel @Inject constructor( @param:ApplicationContext val context: Context, private val tokenDataManager: TokenDataManager, private val dateUtils: DateUtils, - private val prefs: TextSecurePreferences, + private val loginStateRepository: LoginStateRepository, private val conversationRepository: ConversationRepository, ) : ViewModel() { private val TAG = "TokenPageVM" @@ -239,7 +236,7 @@ class TokenPageViewModel @Inject constructor( // Note: We pass this in to the token page so we can call it when we refresh the page. private suspend fun getNodeData() { withContext(Dispatchers.Default) { - val myPublicKey = prefs.getLocalNumber() ?: return@withContext + val myPublicKey = loginStateRepository.requireLocalNumber() val getSwarmSetPromise: Promise, Exception> = SnodeAPI.getSwarm(myPublicKey) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt index 3e7df9f5fe..99e8af2de3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt @@ -7,12 +7,15 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight +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.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.BasicAlertDialog @@ -36,7 +39,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString 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 androidx.compose.ui.window.DialogProperties import com.squareup.phrase.Phrase import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.URL_KEY @@ -48,6 +53,9 @@ import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.blackAlpha40 import org.thoughtcrime.securesms.ui.theme.bold data class DialogButtonData( @@ -110,7 +118,7 @@ fun AlertDialog( showCloseButton: Boolean = false, content: @Composable () -> Unit = {} ) { - BasicAlertDialog( + BasicSessionAlertDialog( modifier = modifier, onDismissRequest = onDismissRequest, content = { @@ -127,6 +135,36 @@ fun AlertDialog( ) } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BasicSessionAlertDialog( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit = {} +){ + BasicAlertDialog( + modifier = modifier, + onDismissRequest = onDismissRequest, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false, + decorFitsSystemWindows = true + ), + content = { + // control content size + Box( + modifier = Modifier + .widthIn(max = LocalDimensions.current.maxDialogWidth) + .fillMaxWidth(0.85f), + contentAlignment = Alignment.Center + ) { + content() + } + } + ) +} + @Composable fun AlertDialogContent( onDismissRequest: () -> Unit, @@ -147,7 +185,7 @@ fun AlertDialogContent( Icon( painter = painterResource(id = R.drawable.ic_x), tint = LocalColors.current.text, - contentDescription = "back" + contentDescription = stringResource(R.string.close) ) } } @@ -225,6 +263,8 @@ fun OpenURLAlertDialog( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, url: String, + onLinkOpened: (String) -> Unit = {}, + onLinkCopied: (String) -> Unit = {}, content: @Composable () -> Unit = {} ) { val context = LocalContext.current @@ -242,11 +282,15 @@ fun OpenURLAlertDialog( DialogButtonData( text = GetString(R.string.open), color = LocalColors.current.danger, - onClick = { context.openUrl(url) } + onClick = { + onLinkOpened(url) + context.openUrl(url) + } ), DialogButtonData( text = GetString(android.R.string.copyUrl), onClick = { + onLinkCopied(url) context.copyURLToClipboard(url) Toast.makeText(context, R.string.copied, Toast.LENGTH_SHORT).show() } @@ -319,17 +363,14 @@ fun LoadingDialog( modifier: Modifier = Modifier, title: String? = null, ){ - BasicAlertDialog( + BasicSessionAlertDialog( modifier = modifier, onDismissRequest = {}, content = { if (title.isNullOrBlank()) { - Box { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center), - color = LocalColors.current.accent - ) - } + CircularProgressIndicator( + color = LocalColors.current.accent + ) } else { DialogBg { Column( @@ -430,8 +471,86 @@ fun PreviewOpenURLDialog() { @Composable fun PreviewLoadingDialog() { PreviewTheme { - LoadingDialog( - title = stringResource(R.string.warning) + Box(Modifier.background(Color.White).fillMaxSize()) { + LoadingDialog() + } + } +} + +@Preview +@Composable +fun PreviewLoadingTextDialog() { + PreviewTheme { + Box(Modifier.background(Color.White).fillMaxSize()) { + LoadingDialog( + title = stringResource(R.string.warning) + ) + } + } +} + +@Composable +fun TCPolicyDialog( + tcsUrl: String, + privacyUrl: String, + onDismissRequest: () -> Unit +){ + AlertDialog( + onDismissRequest = onDismissRequest, + title = stringResource(R.string.urlOpen), + text = stringResource(R.string.urlOpenBrowser), + showCloseButton = true, + content = { + Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + Cell( + bgColor = LocalColors.current.backgroundTertiary + ) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + val context = LocalContext.current + val spacing = LocalDimensions.current.xsSpacing + + IconActionRowItem( + title = annotatedStringResource(tcsUrl), + textStyle = LocalType.current.large.bold(), + icon = R.drawable.ic_square_arrow_up_right, + iconSize = LocalDimensions.current.iconSmall, + paddingValues = PaddingValues(start = spacing), + qaTag = R.string.AccessibilityId_onboardingTos, + onClick = { + context.openUrl(tcsUrl) + } + ) + Divider(paddingValues = PaddingValues(horizontal = spacing)) + + IconActionRowItem( + title = annotatedStringResource(privacyUrl), + textStyle = LocalType.current.large.bold(), + icon = R.drawable.ic_square_arrow_up_right, + iconSize = LocalDimensions.current.iconSmall, + paddingValues = PaddingValues(start = spacing), + qaTag = R.string.AccessibilityId_onboardingPrivacy, + onClick = { + context.openUrl(privacyUrl) + } + ) + } + } + } + ) +} + +@Preview +@Composable +private fun PreviewCPolicyDialog( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + TCPolicyDialog( + tcsUrl = "https://getsession.org/", + privacyUrl = "https://getsession.org/", + onDismissRequest = {} ) } } \ No newline at end of file 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 14e4d43a06..31775fd2d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -2,14 +2,29 @@ package org.thoughtcrime.securesms.ui import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.SizeTransform import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -17,20 +32,24 @@ 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.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope 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.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState @@ -44,6 +63,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -55,6 +75,7 @@ import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -64,6 +85,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.BlendMode @@ -79,19 +101,23 @@ import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.min import androidx.compose.ui.unit.times import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -99,6 +125,7 @@ import kotlinx.coroutines.launch import network.loki.messenger.R import org.thoughtcrime.securesms.ui.components.AccentOutlineButton import org.thoughtcrime.securesms.ui.components.SessionSwitch +import org.thoughtcrime.securesms.ui.components.SlimFillButtonRect import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator import org.thoughtcrime.securesms.ui.components.TitledRadioButton import org.thoughtcrime.securesms.ui.components.annotatedStringResource @@ -106,6 +133,8 @@ import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.ui.theme.primaryBlue import org.thoughtcrime.securesms.ui.theme.primaryGreen import org.thoughtcrime.securesms.ui.theme.primaryOrange @@ -125,11 +154,11 @@ fun AccountIdHeader( horizontal = LocalDimensions.current.contentSpacing, vertical = LocalDimensions.current.xxsSpacing ) -){ +) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, - ){ + ) { Box( modifier = Modifier .weight(1f) @@ -142,8 +171,7 @@ fun AccountIdHeader( .border( shape = MaterialTheme.shapes.large ) - .padding(textPaddingValues) - , + .padding(textPaddingValues), text = text, style = textStyle.copy(color = LocalColors.current.textSecondary) ) @@ -202,7 +230,7 @@ fun PathDot( @Preview @Composable -fun PreviewPathDot(){ +fun PreviewPathDot() { PreviewTheme { Box( modifier = Modifier.padding(20.dp) @@ -227,8 +255,11 @@ data class OptionsCardData( val title: GetString?, val options: List> ) { - constructor(title: GetString, vararg options: RadioOption): this(title, options.asList()) - constructor(@StringRes title: Int, vararg options: RadioOption): this(GetString(title), options.asList()) + constructor(title: GetString, vararg options: RadioOption) : this(title, options.asList()) + constructor(@StringRes title: Int, vararg options: RadioOption) : this( + GetString(title), + options.asList() + ) } @Composable @@ -286,7 +317,8 @@ fun ItemButton( painter = painterResource(id = iconRes), contentDescription = null, tint = iconTint ?: colors.contentColor, - modifier = Modifier.align(Alignment.Center) + modifier = Modifier + .align(Alignment.Center) .size(iconSize) ) }, @@ -318,7 +350,8 @@ fun ItemButton( onClick: () -> Unit ) { TextButton( - modifier = modifier.fillMaxWidth() + modifier = modifier + .fillMaxWidth() .heightIn(min = minHeight), colors = colors, onClick = onClick, @@ -351,14 +384,15 @@ fun ItemButton( subtitle?.let { Text( text = it, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .qaTag(subtitleQaTag), style = LocalType.current.small, ) } } - endIcon?.let{ + endIcon?.let { Spacer(Modifier.width(LocalDimensions.current.smallSpacing)) Box( @@ -387,12 +421,12 @@ fun Cell( modifier: Modifier = Modifier, dropShadow: Boolean = false, bgColor: Color = LocalColors.current.backgroundSecondary, - content: @Composable () -> Unit + content: @Composable BoxScope.() -> Unit ) { Box( modifier = modifier .then( - if(dropShadow) + if (dropShadow) Modifier.dropShadow( shape = MaterialTheme.shapes.small, shadow = Shadow( @@ -425,7 +459,7 @@ fun getCellTopShape() = RoundedCornerShape( @Composable fun getCellBottomShape() = RoundedCornerShape( - topStart = 0.dp, + topStart = 0.dp, topEnd = 0.dp, bottomEnd = LocalDimensions.current.shapeSmall, bottomStart = LocalDimensions.current.shapeSmall @@ -439,11 +473,11 @@ fun CategoryCell( dropShadow: Boolean = false, content: @Composable () -> Unit, -){ + ) { Column( modifier = modifier.fillMaxWidth() ) { - if(!title.isNullOrEmpty() || titleIcon != null) { + if (!title.isNullOrEmpty() || titleIcon != null) { Row( modifier = Modifier.padding( start = LocalDimensions.current.smallSpacing, @@ -464,12 +498,12 @@ fun CategoryCell( } } - Cell( - modifier = Modifier.fillMaxWidth(), - dropShadow = dropShadow - ){ + Cell( + modifier = Modifier.fillMaxWidth(), + dropShadow = dropShadow + ) { content() - } + } } } @@ -510,9 +544,11 @@ private fun BottomFadingEdgeBoxPreview() { content = { bottomContentPadding -> LazyColumn(contentPadding = PaddingValues(bottom = bottomContentPadding)) { items(200) { - Text("Item $it", + Text( + "Item $it", color = LocalColors.current.text, - style = LocalType.current.base) + style = LocalType.current.base + ) } } }, @@ -609,11 +645,17 @@ fun LaunchedEffectAsync(block: suspend CoroutineScope.() -> Unit) { @Composable fun LoadingArcOr(loading: Boolean, content: @Composable () -> Unit) { - AnimatedVisibility(loading) { - SmallCircularProgressIndicator(color = LocalContentColor.current) - } - AnimatedVisibility(!loading) { - content() + AnimatedContent( + targetState = loading, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + contentAlignment = Alignment.Center, + label = "LoadingArcOr" + ) { isLoading -> + if (isLoading) { + SmallCircularProgressIndicator(color = LocalContentColor.current) + } else { + content() + } } } @@ -631,7 +673,7 @@ fun SpeechBubbleTooltip( state = tooltipState, modifier = modifier, positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), - tooltip = { + tooltip = { val bubbleColor = LocalColors.current.backgroundBubbleReceived Card( @@ -731,6 +773,287 @@ fun SearchBar( ) } +/** + * CollapsibleFooterAction + */ +@Composable +fun CollapsibleFooterAction( + modifier: Modifier = Modifier, + data: CollapsibleFooterActionData, + onCollapsedClicked: () -> Unit = {}, + onClosedClicked: () -> Unit = {} +) { + + // Bottomsheet-like enter/exit + val enterFromBottom = remember { + slideInVertically( + // start completely off-screen below + initialOffsetY = { it }, + animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing) + ) + fadeIn() + } + val exitToBottom = remember { + slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(durationMillis = 200, easing = FastOutLinearInEasing) + ) + fadeOut() + } + + AnimatedVisibility( + // drives show/hide from bottom + visible = data.visible, + enter = enterFromBottom, + exit = exitToBottom, + ) { + Column( + modifier = modifier + .fillMaxWidth() + .clip( + RoundedCornerShape( + topStart = LocalDimensions.current.contentSpacing, + topEnd = LocalDimensions.current.contentSpacing + ) + ) + .background(LocalColors.current.backgroundSecondary) + .animateContentSize() + .padding( + horizontal = LocalDimensions.current.smallSpacing, + vertical = LocalDimensions.current.xxsSpacing + ), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + val rotation by animateFloatAsState( + targetValue = if (data.collapsed) 180f else 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ) + ) + + IconButton( + modifier = Modifier.rotate(rotation), + onClick = onCollapsedClicked + ) { + Icon( + painter = painterResource(R.drawable.ic_chevron_down), + contentDescription = null + ) + } + Text( + text = data.title.string(), + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = LocalDimensions.current.smallSpacing), + style = LocalType.current.h8, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + IconButton( + onClick = onClosedClicked + ) { + Icon( + painter = painterResource(R.drawable.ic_x), + contentDescription = null + ) + } + } + + val showActions = data.visible && !data.collapsed + // Rendered actions + AnimatedVisibility( + visible = showActions, + enter = expandVertically( + animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing), + expandFrom = Alignment.Top + ) + fadeIn(animationSpec = tween(durationMillis = 120)), + exit = shrinkVertically( + animationSpec = tween(durationMillis = 100, easing = FastOutLinearInEasing), + shrinkTowards = Alignment.Top + ) + fadeOut(animationSpec = tween(durationMillis = 80)) + ) { + CategoryCell(modifier = Modifier.padding(bottom = LocalDimensions.current.smallSpacing)) { + CollapsibleFooterActions(items = data.items) + } + } + } + } +} + +@Composable +private fun CollapsibleFooterActions( + items: List, + buttonWidthCapFraction: Float = 1f / 3f // criteria +) { + // rules for this: + // Max width should be approx 1/3 of the available space (buttonWidthCapFraction) + // Buttons should have matching widths + + BoxWithConstraints(Modifier.fillMaxWidth()) { + val density = LocalDensity.current + val capPx = (constraints.maxWidth * buttonWidthCapFraction).toInt() + val capDp = with(density) { capPx.toDp() } + + val single = items.size == 1 + val measuredMaxButtonWidthPx = remember(items, capPx) { mutableIntStateOf(1) } + + // Only do the offscreen equal width computation when we have 2+ buttons. + if (!single) { + SubcomposeLayout { parentConstraints -> + val measurables = subcompose("measureButtons") { + items.forEach { item -> + SlimFillButtonRect(item.buttonLabel.string(), color = item.buttonColor) {} + } + } + val placeables = measurables.map { m -> + m.measure( + Constraints( + minWidth = 1, + maxWidth = capPx, + minHeight = 0, + maxHeight = parentConstraints.maxHeight + ) + ) + } + val natural = placeables.maxOfOrNull { it.width } ?: 1 + measuredMaxButtonWidthPx.intValue = natural.coerceIn(1, capPx) + layout(0, 0) {} + } + } + + val equalWidthDp = with(density) { measuredMaxButtonWidthPx.intValue.toDp() } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalColors.current.backgroundTertiary) + ) { + items.forEachIndexed { index, item -> + if (index != 0) Divider() + + val titleText = item.label() + val annotatedTitle = remember(titleText) { AnnotatedString(titleText) } + + ActionRowItem( + modifier = Modifier.background(LocalColors.current.backgroundTertiary) + .semantics(mergeDescendants = true){}, + title = annotatedTitle, + onClick = { + item.onClick() + }, + qaTag = R.string.qa_collapsing_footer_action, + endContent = { + Box( + modifier = Modifier + .padding(start = LocalDimensions.current.smallSpacing) + .then( + if (single) Modifier.wrapContentWidth().widthIn(max = capDp) + else Modifier.width(equalWidthDp) + ) + ) { + val buttonModifier = if (single) Modifier else Modifier.fillMaxWidth() + SlimFillButtonRect( + modifier = buttonModifier + .qaTag(stringResource(R.string.qa_collapsing_footer_action)+"_"+item.buttonLabel.string().lowercase()) + .clearAndSetSemantics{}, + text = item.buttonLabel.string(), + color = item.buttonColor + ) { + item.onClick() + } + } + } + ) + } + } + } +} + +data class CollapsibleFooterActionData( + val title: GetString, + val collapsed: Boolean, + val visible: Boolean, + val items: List +) + +data class CollapsibleFooterItemData( + val label: GetString, + val buttonLabel: GetString, + val buttonColor: Color, + val onClick: () -> Unit +) + + +@Preview +@Composable +fun PreviewCollapsibleActionTray( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + val demoItems = listOf( + CollapsibleFooterItemData( + label = GetString("Invite "), + buttonLabel = GetString("Invite"), + buttonColor = LocalColors.current.accent, + onClick = {} + ), + CollapsibleFooterItemData( + label = GetString("Delete"), + buttonLabel = GetString("2"), + buttonColor = LocalColors.current.danger, + onClick = {} + ) + ) + + CollapsibleFooterAction( + data = CollapsibleFooterActionData( + title = GetString("Invite Contacts"), + collapsed = false, + visible = true, + items = demoItems + ) + ) + } +} + +@Preview +@Composable +fun PreviewCollapsibleActionTrayLongText( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + val demoItems = listOf( + CollapsibleFooterItemData( + label = GetString("Looooooooooooooooooooooooooooooooooooooooooooooooooooooooong"), + buttonLabel = GetString("Long Looooooooooooooooooooong"), + buttonColor = LocalColors.current.accent, + onClick = {} + ), + CollapsibleFooterItemData( + label = GetString("Delete"), + buttonLabel = GetString("Delete"), + buttonColor = LocalColors.current.danger, + onClick = {} + ) + ) + + CollapsibleFooterAction( + data = CollapsibleFooterActionData( + title = GetString("Invite Contacts"), + collapsed = false, + visible = true, + items = demoItems + ) + ) + } +} + @Preview @Composable fun PreviewSearchBar() { @@ -761,14 +1084,15 @@ fun ExpandableText( expandedMaxLines: Int = Int.MAX_VALUE, expandButtonText: String = stringResource(id = R.string.viewMore), collapseButtonText: String = stringResource(id = R.string.viewLess), -){ +) { var expanded by remember { mutableStateOf(false) } var showButton by remember { mutableStateOf(false) } var maxHeight by remember { mutableStateOf(Dp.Unspecified) } val density = LocalDensity.current - val enableScrolling = expanded && maxHeight != Dp.Unspecified && expandedMaxLines != Int.MAX_VALUE + val enableScrolling = + expanded && maxHeight != Dp.Unspecified && expandedMaxLines != Int.MAX_VALUE BaseExpandableText( text = text, @@ -789,10 +1113,11 @@ fun ExpandableText( onTextMeasured = { textLayoutResult -> showButton = expanded || textLayoutResult.hasVisualOverflow val lastVisible = (expandedMaxLines - 1).coerceAtMost(textLayoutResult.lineCount - 1) - val px = textLayoutResult.getLineBottom(lastVisible) // bottom of that line in px + val px = + textLayoutResult.getLineBottom(lastVisible) // bottom of that line in px maxHeight = with(density) { px.toDp() } }, - onTap = if(showButton){ // only expand if there is enough text + onTap = if (showButton) { // only expand if there is enough text { expanded = !expanded } } else null ) @@ -851,11 +1176,11 @@ fun BaseExpandableText( showScroll: Boolean = false, onTextMeasured: (TextLayoutResult) -> Unit = {}, onTap: (() -> Unit)? = null -){ +) { var textModifier: Modifier = Modifier - if(qaTag != null) textModifier = textModifier.qaTag(qaTag) - if(expanded) textModifier = textModifier.height(expandedMaxHeight) - if(showScroll){ + if (qaTag != null) textModifier = textModifier.qaTag(qaTag) + if (expanded) textModifier = textModifier.height(expandedMaxHeight) + if (showScroll) { val scrollState = rememberScrollState() val scrollEdge = LocalDimensions.current.xxxsSpacing val scrollWidth = 2.dp @@ -871,7 +1196,7 @@ fun BaseExpandableText( Column( modifier = modifier.then( - if(onTap != null) Modifier.clickable { onTap() } else Modifier + if (onTap != null) Modifier.clickable { onTap() } else Modifier ), horizontalAlignment = Alignment.CenterHorizontally ) { @@ -888,7 +1213,7 @@ fun BaseExpandableText( overflow = if (expanded) TextOverflow.Clip else TextOverflow.Ellipsis ) - if(showButton) { + if (showButton) { Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing)) Text( text = if (expanded) collapseButtonText else expandButtonText, @@ -1010,6 +1335,7 @@ fun ActionRowItem( onClick: () -> Unit, @StringRes qaTag: Int, modifier: Modifier = Modifier, + enabled: Boolean = true, subtitle: AnnotatedString? = null, titleColor: Color = LocalColors.current.text, subtitleColor: Color = LocalColors.current.text, @@ -1018,10 +1344,13 @@ fun ActionRowItem( minHeight: Dp = LocalDimensions.current.minItemButtonHeight, paddingValues: PaddingValues = PaddingValues(horizontal = LocalDimensions.current.smallSpacing), endContent: @Composable (() -> Unit)? = null -){ +) { Row( - modifier = modifier.heightIn(min = minHeight) - .clickable { onClick() } + modifier = modifier + .heightIn(min = minHeight) + .then( + if (enabled) Modifier.clickable { onClick() } else Modifier + ) .padding(paddingValues) .qaTag(qaTag), verticalAlignment = Alignment.CenterVertically @@ -1074,7 +1403,7 @@ fun IconActionRowItem( iconSize: Dp = LocalDimensions.current.iconMedium, minHeight: Dp = LocalDimensions.current.minItemButtonHeight, paddingValues: PaddingValues = PaddingValues(horizontal = LocalDimensions.current.smallSpacing), -){ +) { ActionRowItem( modifier = modifier, title = title, @@ -1092,7 +1421,8 @@ fun IconActionRowItem( modifier = Modifier.size(LocalDimensions.current.itemButtonIconSpacing) ) { Icon( - modifier = Modifier.align(Alignment.Center) + modifier = Modifier + .align(Alignment.Center) .size(iconSize) .qaTag(R.string.qa_action_item_icon), painter = painterResource(id = icon), @@ -1118,7 +1448,7 @@ fun SwitchActionRowItem( subtitleStyle: TextStyle = LocalType.current.small, paddingValues: PaddingValues = PaddingValues(horizontal = LocalDimensions.current.smallSpacing), minHeight: Dp = LocalDimensions.current.minItemButtonHeight, -){ +) { ActionRowItem( modifier = modifier, title = title, @@ -1142,7 +1472,7 @@ fun SwitchActionRowItem( @Preview @Composable -fun PreviewActionRowItems(){ +fun PreviewActionRowItems() { PreviewTheme { Column( modifier = Modifier.padding(20.dp), diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt index a202ddd64c..527a5e6f23 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt @@ -15,28 +15,41 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider 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 @@ -45,10 +58,12 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics @@ -60,18 +75,26 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideSubcomposition import com.bumptech.glide.integration.compose.RequestState import com.squareup.phrase.Phrase +import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.LIMIT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsNavHost +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.Avatar -import org.thoughtcrime.securesms.ui.components.FillButtonRect +import org.thoughtcrime.securesms.ui.components.BaseBottomSheet import org.thoughtcrime.securesms.ui.components.QrImage import org.thoughtcrime.securesms.ui.components.TertiaryFillButtonRect import org.thoughtcrime.securesms.ui.theme.LocalColors @@ -204,89 +227,192 @@ private fun PreviewProBadgeText( } } +/** + * This composable comprises of the CTA itself + * and the bottom sheet with the whole pro settings content + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun SessionProCTA( content: @Composable () -> Unit, - text: String, - features: List, + textContent: @Composable ColumnScope.() -> Unit, modifier: Modifier = Modifier, - onUpgrade: () -> Unit, - onCancel: () -> Unit, + title: String = stringResource(R.string.upgradeTo), + titleColor: Color = LocalColors.current.text, + showProBadge: Boolean = true, + badgeAtStart: Boolean = false, + disabled: Boolean = false, + features: List = emptyList(), + positiveButtonText: String? = stringResource(R.string.theContinue), + negativeButtonText: String? = stringResource(R.string.cancel), + onUpgrade: (() -> Unit)? = null, + onCancel: () -> Unit = {}, ){ - BasicAlertDialog( - modifier = modifier, - onDismissRequest = onCancel, - content = { - DialogBg { - Column(modifier = Modifier.fillMaxWidth()) { - // hero image - BottomFadingEdgeBox( - fadingEdgeHeight = 70.dp, - fadingColor = LocalColors.current.backgroundSecondary, - content = { _ -> - content() - }, - ) + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + val scope = rememberCoroutineScope() - // content - Column( - modifier = Modifier - .fillMaxWidth() - .padding(LocalDimensions.current.smallSpacing) - ) { - // title - ProBadgeText( - modifier = Modifier.align(Alignment.CenterHorizontally), - text = stringResource(R.string.upgradeTo) - ) + // We should avoid internal state in a composable but having the bottom sheet + // here avoids re-defining the sheet in multiple places in the app + var showDialog by remember { mutableStateOf(true) } + var showProSheet by remember { mutableStateOf(false) } - Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) + // default handling of the upgrade button + val defaultUpgrade: () -> Unit = { + showProSheet = true + showDialog = false + } - // main message - Text( - modifier = Modifier.align(Alignment.CenterHorizontally), - text = text, - textAlign = TextAlign.Center, - style = LocalType.current.base.copy( - color = LocalColors.current.textSecondary + if(showDialog) { + BasicSessionAlertDialog( + modifier = modifier, + onDismissRequest = onCancel, + content = { + DialogBg { + BoxWithConstraints(Modifier.fillMaxWidth()) { + val heroMaxHeight = maxHeight * 0.4f + Column(modifier = Modifier.fillMaxWidth() + .verticalScroll(rememberScrollState())) { + // hero image + BottomFadingEdgeBox( + modifier = Modifier.heightIn(max = heroMaxHeight), + fadingEdgeHeight = 70.dp, + fadingColor = LocalColors.current.backgroundSecondary, + content = { _ -> + content() + }, ) - ) - Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) + // content + Column( + modifier = Modifier + .fillMaxWidth() + .padding(LocalDimensions.current.smallSpacing) + ) { + // title + ProBadgeText( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = title, + textStyle = LocalType.current.h5.copy(color = titleColor), + showBadge = showProBadge, + badgeAtStart = badgeAtStart, + badgeColors = if (disabled) proBadgeColorDisabled() else proBadgeColorStandard(), + ) - // features - features.forEachIndexed { index, feature -> - ProCTAFeature(data = feature) - if(index < features.size - 1){ - Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) + + // main message + textContent() + + Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) + + // features + if (features.isNotEmpty()) { + features.forEachIndexed { index, feature -> + ProCTAFeature( + modifier = Modifier.qaTag(stringResource(R.string.qa_cta_feature) + index.toString()), + data = feature + ) + if (index < features.size - 1) { + Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + } + } + + Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) + } + + // buttons + Row( + Modifier.height(IntrinsicSize.Min) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + LocalDimensions.current.xsSpacing, + Alignment.CenterHorizontally + ), + ) { + positiveButtonText?.let { + AccentFillButtonRect( + modifier = Modifier + .qaTag(R.string.qa_cta_button_positive) + .then( + if (negativeButtonText != null) + Modifier.weight(1f) + else Modifier + ).shimmerOverlay(), + text = it, + onClick = onUpgrade ?: defaultUpgrade + ) + } + + negativeButtonText?.let { + TertiaryFillButtonRect( + modifier = Modifier + .qaTag(R.string.qa_cta_button_negative) + .then( + if (positiveButtonText != null) + Modifier.weight(1f) + else Modifier + ), + text = it, + onClick = onCancel + ) + } + } } } + } + } + } + ) + } - Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) - - // buttons - Row( - Modifier.height(IntrinsicSize.Min), - horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xsSpacing), - ) { - AccentFillButtonRect( - modifier = Modifier.weight(1f).shimmerOverlay(), - text = stringResource(R.string.theContinue), - onClick = onUpgrade - ) + if(showProSheet) { + val dismissSheet: () -> Unit = { + scope.launch { + sheetState.hide() + onCancel() + } + } - TertiaryFillButtonRect( - modifier = Modifier.weight(1f), - text = stringResource(R.string.cancel), - onClick = onCancel - ) - } + BaseBottomSheet( + modifier = modifier, + sheetState = sheetState, + dragHandle = null, + onDismissRequest = dismissSheet + ) { + BoxWithConstraints(modifier = modifier) { + val topInset = WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() + val maxHeight = + (this.maxHeight - topInset) * 0.85f // sheet should take up 80% of the height, without the status bar + + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = maxHeight), + contentAlignment = Alignment.TopCenter + ) { + ProSettingsNavHost( + startDestination = ProSettingsDestination.Home, + inSheet = true, + onBack = dismissSheet + ) + + IconButton( + onClick = dismissSheet, + modifier = Modifier.align(Alignment.TopEnd) + .padding(10.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_x), + tint = LocalColors.current.text, + contentDescription = stringResource(R.string.close) + ) } } } } - ) + } } sealed interface CTAFeature { @@ -307,15 +433,36 @@ sealed interface CTAFeature { fun SimpleSessionProCTA( @DrawableRes heroImage: Int, text: String, - features: List, modifier: Modifier = Modifier, - onUpgrade: () -> Unit, - onCancel: () -> Unit, + title: String = stringResource(R.string.upgradeTo), + showProBadge: Boolean = true, + badgeAtStart: Boolean = false, + features: List = emptyList(), + positiveButtonText: String? = stringResource(R.string.theContinue), + negativeButtonText: String? = stringResource(R.string.cancel), + onUpgrade: (() -> Unit)? = null, + onCancel: () -> Unit = {}, ){ SessionProCTA( modifier = modifier, - text = text, + title = title, + showProBadge = showProBadge, + badgeAtStart = badgeAtStart, + textContent = { + Text( + modifier = Modifier + .qaTag(R.string.qa_cta_body) + .align(Alignment.CenterHorizontally), + text = text, + textAlign = TextAlign.Center, + style = LocalType.current.base.copy( + color = LocalColors.current.textSecondary + ) + ) + }, features = features, + positiveButtonText = positiveButtonText, + negativeButtonText = negativeButtonText, onUpgrade = onUpgrade, onCancel = onCancel, content = { CTAImage(heroImage = heroImage) } @@ -342,23 +489,49 @@ fun AnimatedSessionProCTA( @DrawableRes heroImageBg: Int, @DrawableRes heroImageAnimatedFg: Int, text: String, - features: List, modifier: Modifier = Modifier, - onUpgrade: () -> Unit, - onCancel: () -> Unit, + title: String = stringResource(R.string.upgradeTo), + showProBadge: Boolean = true, + badgeAtStart: Boolean = false, + disabled: Boolean = false, + features: List = emptyList(), + positiveButtonText: String? = stringResource(R.string.theContinue), + negativeButtonText: String? = stringResource(R.string.cancel), + onUpgrade: (() -> Unit)? = null, + onCancel: () -> Unit = {}, ){ SessionProCTA( modifier = modifier, - text = text, + textContent = { + Text( + modifier = Modifier + .qaTag(R.string.qa_cta_body) + .align(Alignment.CenterHorizontally), + text = text, + textAlign = TextAlign.Center, + style = LocalType.current.base.copy( + color = LocalColors.current.textSecondary + ) + ) + }, features = features, onUpgrade = onUpgrade, onCancel = onCancel, content = { CTAAnimatedImages( heroImageBg = heroImageBg, - heroImageAnimatedFg = heroImageAnimatedFg + heroImageAnimatedFg = heroImageAnimatedFg, + disabled = disabled ) - }) + }, + positiveButtonText = positiveButtonText, + negativeButtonText = negativeButtonText, + title = title, + titleColor = if(disabled) LocalColors.current.disabled else LocalColors.current.text, + showProBadge = showProBadge, + badgeAtStart = badgeAtStart, + disabled = disabled + ) } @OptIn(ExperimentalGlideComposeApi::class) @@ -366,11 +539,15 @@ fun AnimatedSessionProCTA( fun CTAAnimatedImages( @DrawableRes heroImageBg: Int, @DrawableRes heroImageAnimatedFg: Int, + disabled: Boolean = false ){ Image( modifier = Modifier .fillMaxWidth() - .background(LocalColors.current.accent), + .background( + if(disabled) LocalColors.current.disabled + else LocalColors.current.accent + ), contentScale = ContentScale.FillWidth, painter = painterResource(id = heroImageBg), contentDescription = null, @@ -388,6 +565,9 @@ fun CTAAnimatedImages( modifier = Modifier.fillMaxWidth(), contentScale = ContentScale.FillWidth, painter = painter, + colorFilter = if(disabled) + ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) + else null, contentDescription = null, ) } @@ -397,129 +577,35 @@ fun CTAAnimatedImages( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun SessionProActivatedCTA( - imageContent: @Composable () -> Unit, - modifier: Modifier = Modifier, - title: String, - textContent: @Composable ColumnScope.() -> Unit, - onCancel: () -> Unit, -){ - BasicAlertDialog( - modifier = modifier, - onDismissRequest = onCancel, - content = { - DialogBg { - Column(modifier = Modifier.fillMaxWidth()) { - // hero image - BottomFadingEdgeBox( - fadingEdgeHeight = 70.dp, - fadingColor = LocalColors.current.backgroundSecondary, - content = { _ -> - imageContent() - }, - ) - - // content - Column( - modifier = Modifier - .fillMaxWidth() - .padding(LocalDimensions.current.smallSpacing) - ) { - // title - ProBadgeText( - modifier = Modifier.align(Alignment.CenterHorizontally), - text = title, - textStyle = LocalType.current.h5, - badgeAtStart = true - ) - - Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) - - // already have pro - textContent() - - Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) - - // buttons - TertiaryFillButtonRect( - modifier = Modifier.align(Alignment.CenterHorizontally), - text = stringResource(R.string.close), - onClick = onCancel - ) - } - } - } - } - ) -} - -@Composable -fun SimpleSessionProActivatedCTA( - @DrawableRes heroImage: Int, - title: String, - onCancel: () -> Unit, - modifier: Modifier = Modifier, - textContent: @Composable ColumnScope.() -> Unit -){ - SessionProActivatedCTA( - modifier = modifier, - title = title, - textContent = textContent, - onCancel = onCancel, - imageContent = { CTAImage(heroImage = heroImage) } - ) -} - -@OptIn(ExperimentalGlideComposeApi::class) -@Composable -fun AnimatedSessionProActivatedCTA( - @DrawableRes heroImageBg: Int, - @DrawableRes heroImageAnimatedFg: Int, - title: String, - onCancel: () -> Unit, - modifier: Modifier = Modifier, - textContent: @Composable ColumnScope.() -> Unit -){ - SessionProActivatedCTA( - modifier = modifier, - title = title, - textContent = textContent, - onCancel = onCancel, - imageContent = { - CTAAnimatedImages( - heroImageBg = heroImageBg, - heroImageAnimatedFg = heroImageAnimatedFg - ) - }) -} - // Reusable generic Pro CTA @Composable fun GenericProCTA( + proSubscription: ProStatus, onDismissRequest: () -> Unit, - onPostAction: (() -> Unit)? = null // a function for optional code once an action has been taken ){ val context = LocalContext.current + val expired = proSubscription is ProStatus.Expired + AnimatedSessionProCTA( heroImageBg = R.drawable.cta_hero_generic_bg, heroImageAnimatedFg = R.drawable.cta_hero_generic_fg, - text = Phrase.from(context,R.string.proUserProfileModalCallToAction) + title = if(expired) stringResource(R.string.renew) + else stringResource(R.string.upgradeTo), + text = if(expired) Phrase.from(context,R.string.proRenewMaxPotential) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + .toString() + else Phrase.from(context, R.string.proUserProfileModalCallToAction) .put(APP_NAME_KEY, context.getString(R.string.app_name)) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format() .toString(), features = listOf( - CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), + CTAFeature.Icon(stringResource(R.string.proFeatureListPinnedConversations)), CTAFeature.RainbowIcon(stringResource(R.string.proFeatureListLoadsMore)), ), - onUpgrade = { - onDismissRequest() - onPostAction?.invoke() - //todo PRO go to screen once it exists - }, onCancel = { onDismissRequest() } @@ -529,23 +615,29 @@ fun GenericProCTA( // Reusable long message Pro CTA @Composable fun LongMessageProCTA( + proSubscription: ProStatus, onDismissRequest: () -> Unit, ){ + val expired = proSubscription is ProStatus.Expired + val context = LocalContext.current + SimpleSessionProCTA( heroImage = R.drawable.cta_hero_char_limit, - text = Phrase.from(LocalContext.current, R.string.proCallToActionLongerMessages) + title = if(expired) stringResource(R.string.renew) + else stringResource(R.string.upgradeTo), + text = if(expired) Phrase.from(context,R.string.proRenewLongerMessages) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + .toString() + else Phrase.from(context, R.string.proCallToActionLongerMessages) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format() .toString(), features = listOf( CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), - CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), + CTAFeature.Icon(stringResource(R.string.proFeatureListPinnedConversations)), CTAFeature.RainbowIcon(stringResource(R.string.proFeatureListLoadsMore)), ), - onUpgrade = { - onDismissRequest() - //todo PRO go to screen once it exists - }, onCancel = { onDismissRequest() } @@ -555,24 +647,30 @@ fun LongMessageProCTA( // Reusable animated profile pic Pro CTA @Composable fun AnimatedProfilePicProCTA( + proSubscription: ProStatus, onDismissRequest: () -> Unit, ){ + val expired = proSubscription is ProStatus.Expired + val context = LocalContext.current + AnimatedSessionProCTA( heroImageBg = R.drawable.cta_hero_animated_bg, heroImageAnimatedFg = R.drawable.cta_hero_animated_fg, - text = Phrase.from(LocalContext.current, R.string.proAnimatedDisplayPictureCallToActionDescription) + title = if(expired) stringResource(R.string.renew) + else stringResource(R.string.upgradeTo), + text =if(expired) Phrase.from(context,R.string.proRenewAnimatedDisplayPicture) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + .toString() + else Phrase.from(context, R.string.proAnimatedDisplayPictureCallToActionDescription) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format() .toString(), features = listOf( CTAFeature.Icon(stringResource(R.string.proFeatureListAnimatedDisplayPicture)), - CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), + CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), CTAFeature.RainbowIcon(stringResource(R.string.proFeatureListLoadsMore)), ), - onUpgrade = { - onDismissRequest() - //todo PRO go to screen once it exists - }, onCancel = { onDismissRequest() } @@ -585,56 +683,74 @@ fun AnimatedProfilePicProCTA( @Composable fun PinProCTA( overTheLimit: Boolean, + proSubscription: ProStatus, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, ){ + val expired = proSubscription is ProStatus.Expired val context = LocalContext.current + + val title = when{ + overTheLimit && expired -> Phrase.from(context, R.string.proRenewPinMoreConversations) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + .toString() + + overTheLimit && !expired -> Phrase.from(context, R.string.proCallToActionPinnedConversations) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + .toString() + + !overTheLimit && expired -> Phrase.from(context, R.string.proRenewPinFiveConversations) + .put(LIMIT_KEY, ProStatusManager.MAX_PIN_REGULAR.toString()) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + .toString() + + else -> Phrase.from(context, R.string.proCallToActionPinnedConversationsMoreThan) + .put(LIMIT_KEY, ProStatusManager.MAX_PIN_REGULAR.toString()) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + .toString() + } SimpleSessionProCTA( modifier = modifier, heroImage = R.drawable.cta_hero_pins, - text = if(overTheLimit) - Phrase.from(context, R.string.proCallToActionPinnedConversations) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format() - .toString() - else - Phrase.from(context, R.string.proCallToActionPinnedConversationsMoreThan) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format() - .toString(), + title = if(expired) stringResource(R.string.renew) + else stringResource(R.string.upgradeTo), + text = title, features = listOf( CTAFeature.Icon(stringResource(R.string.proFeatureListPinnedConversations)), - CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), + CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), CTAFeature.RainbowIcon(stringResource(R.string.proFeatureListLoadsMore)), ), - onUpgrade = { - onDismissRequest() - //todo PRO go to screen once it exists - }, onCancel = { onDismissRequest() } ) } -@Preview +@Preview(name = "Compact", widthDp = 200, heightDp = 300) +@Preview(name = "Medium", widthDp = 720, heightDp = 1280) @Composable private fun PreviewProCTA( @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors ) { PreviewTheme(colors) { - SimpleSessionProCTA( - heroImage = R.drawable.cta_hero_char_limit, - text = "This is a description of this Pro feature", - features = listOf( - CTAFeature.Icon("Feature one"), - CTAFeature.Icon("Feature two", R.drawable.ic_eye), - CTAFeature.RainbowIcon("Feature three"), - ), - onUpgrade = {}, - onCancel = {} - ) + Box(Modifier.fillMaxSize()) { + SimpleSessionProCTA( + heroImage = R.drawable.cta_hero_char_limit, + text = "This is a description of this Pro feature", + features = listOf( + CTAFeature.Icon("Feature one"), + CTAFeature.Icon("Feature two", R.drawable.ic_eye), + CTAFeature.RainbowIcon("Feature three"), + ), + onUpgrade = {}, + onCancel = {} + ) + } } } @@ -644,8 +760,7 @@ private fun PreviewProActivatedCTA( @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors ) { PreviewTheme(colors) { - SimpleSessionProActivatedCTA( - heroImage = R.drawable.cta_hero_char_limit, + SessionProCTA( title = stringResource(R.string.proActivated), textContent = { ProBadgeText( @@ -666,6 +781,9 @@ private fun PreviewProActivatedCTA( ) ) }, + content = { CTAImage(heroImage = R.drawable.cta_hero_char_limit) }, + positiveButtonText = null, + negativeButtonText = stringResource(R.string.close), onCancel = {} ) } @@ -984,22 +1102,25 @@ fun SessionProSettingsHeader( Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) - Row( - modifier = Modifier.height(LocalDimensions.current.smallSpacing) - ) { - Image( - painter = painterResource(R.drawable.ic_session), - contentDescription = null, - colorFilter = ColorFilter.tint(LocalColors.current.text) - ) + // Force the row to remain in LTR to preserve the image+icon order + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Row( + modifier = Modifier.height(LocalDimensions.current.smallSpacing) + ) { + Image( + painter = painterResource(R.drawable.ic_session), + contentDescription = null, + colorFilter = ColorFilter.tint(LocalColors.current.text) + ) - Spacer(Modifier.width(LocalDimensions.current.xxxsSpacing)) + Spacer(Modifier.width(LocalDimensions.current.xxxsSpacing)) - ProBadge( - colors = proBadgeColorStandard().copy( - backgroundColor = color + ProBadge( + colors = proBadgeColorStandard().copy( + backgroundColor = color + ) ) - ) + } } extraContent?.let{ diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt index 599873491d..8aa4c05bc3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt @@ -5,10 +5,10 @@ import androidx.navigation.NavOptionsBuilder import dagger.hilt.android.scopes.ActivityRetainedScoped import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow +import org.session.libsignal.utilities.Log import javax.inject.Inject -@ActivityRetainedScoped -class UINavigator @Inject constructor() { +class UINavigator () { private val _navigationActions = Channel>() val navigationActions = _navigationActions.receiveAsFlow() @@ -38,6 +38,10 @@ class UINavigator @Inject constructor() { _navigationActions.send(NavigationAction.NavigateToIntent(intent)) } + suspend fun sendCustomAction(data: Any){ + _navigationActions.send(NavigationAction.PerformCustomAction(data)) + } + suspend fun returnResult(code: String, value: Boolean) { _navigationActions.send(NavigationAction.ReturnResult(code, value)) } @@ -59,4 +63,8 @@ sealed interface NavigationAction { val code: String, val value: Boolean ) : NavigationAction + + data class PerformCustomAction( + val data: Any + ) : NavigationAction } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/UserProfileModal.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/UserProfileModal.kt index 811532e20a..7e56efeb67 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/UserProfileModal.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/UserProfileModal.kt @@ -38,6 +38,7 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.ui.components.SlimAccentOutlineButton import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton import org.thoughtcrime.securesms.ui.components.annotatedStringResource @@ -51,6 +52,7 @@ import org.thoughtcrime.securesms.ui.theme.monospace import org.thoughtcrime.securesms.ui.theme.primaryRed import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUIElement +import org.thoughtcrime.securesms.util.GenericCTAData import org.thoughtcrime.securesms.util.UserProfileModalCommands import org.thoughtcrime.securesms.util.UserProfileModalData @@ -220,8 +222,9 @@ fun UserProfileModal( ) // the pro CTA that comes with UPM - if(data.showProCTA){ + if(data.showProCTA != null){ GenericProCTA( + proSubscription = data.showProCTA.proSubscription, onDismissRequest = { sendCommand(UserProfileModalCommands.HideSessionProCTA) }, @@ -250,7 +253,7 @@ private fun PreviewUPM( enableMessage = true, expandedAvatar = false, showQR = false, - showProCTA = false, + showProCTA = null, avatarUIData = AvatarUIData( listOf( AvatarUIElement( @@ -270,10 +273,10 @@ private fun PreviewUPM( sendCommand = { command -> when(command){ UserProfileModalCommands.ShowProCTA -> { - data = data.copy(showProCTA = true) + data = data.copy(showProCTA = GenericCTAData(ProStatus.NeverSubscribed)) } UserProfileModalCommands.HideSessionProCTA -> { - data = data.copy(showProCTA = false) + data = data.copy(showProCTA = null) } UserProfileModalCommands.ToggleQR -> { data = data.copy(showQR = !data.showQR) @@ -310,7 +313,7 @@ private fun PreviewUPMResolved( enableMessage = true, expandedAvatar = false, showQR = true, - showProCTA = false, + showProCTA = null, avatarUIData = AvatarUIData( listOf( AvatarUIElement( @@ -330,10 +333,10 @@ private fun PreviewUPMResolved( sendCommand = { command -> when(command){ UserProfileModalCommands.ShowProCTA -> { - data = data.copy(showProCTA = true) + data = data.copy(showProCTA = GenericCTAData(ProStatus.NeverSubscribed)) } UserProfileModalCommands.HideSessionProCTA -> { - data = data.copy(showProCTA = false) + data = data.copy(showProCTA = null) } UserProfileModalCommands.ToggleQR -> { data = data.copy(showQR = !data.showQR) @@ -371,7 +374,7 @@ private fun PreviewUPMQR( enableMessage = false, expandedAvatar = false, showQR = false, - showProCTA = false, + showProCTA = null, avatarUIData = AvatarUIData( listOf( AvatarUIElement( @@ -413,7 +416,7 @@ private fun PreviewUPMCTA( enableMessage = false, expandedAvatar = true, showQR = false, - showProCTA = true, + showProCTA = GenericCTAData(ProStatus.NeverSubscribed), avatarUIData = AvatarUIData( listOf( AvatarUIElement( diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/BottomSheets.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/BottomSheets.kt index 1cfb7701cb..8100afa6f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/BottomSheets.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/BottomSheets.kt @@ -62,8 +62,8 @@ fun BaseBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, shape = RoundedCornerShape( - topStart = LocalDimensions.current.xsSpacing, - topEnd = LocalDimensions.current.xsSpacing + topStart = LocalDimensions.current.contentSpacing, + topEnd = LocalDimensions.current.contentSpacing ), dragHandle = dragHandle, containerColor = LocalColors.current.backgroundSecondary 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 index ef9215cace..d54ceceb30 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt @@ -73,7 +73,8 @@ fun Button( style.applyButtonConstraints { androidx.compose.material3.Button( onClick = onClick, - modifier = modifier.heightIn(min = style.minHeight) + modifier = modifier + .heightIn(min = style.minHeight) .defaultMinSize(minWidth = minWidth), enabled = enabled, interactionSource = interactionSource, @@ -352,6 +353,27 @@ fun BorderlessHtmlButton( } } +@Composable +fun SlimFillButtonRect( + text: String, + modifier: Modifier = Modifier, + color: Color = LocalColors.current.accent, + enabled: Boolean = true, + minWidth: Dp = 0.dp, + onClick: () -> Unit +) { + Button( + text, onClick, + ButtonType.Fill(color), + modifier, + enabled, + style = ButtonStyle.Slim, + shape = sessionShapes().extraSmall, + minWidth = minWidth + ) +} + + val MutableInteractionSource.releases get() = interactions.filter { it is PressInteraction.Release } @@ -380,6 +402,7 @@ private fun VariousButtons( SlimOutlineButton("Slim Outline Disabled", enabled = false) {} SlimAccentOutlineButton("Slim Accent") {} SlimOutlineButton("Slim Danger", color = LocalColors.current.danger) {} + SlimFillButtonRect("Slim Fill", color = LocalColors.current.accent) {} BorderlessButton("Borderless Button") {} BorderlessButton("Borderless Secondary", color = LocalColors.current.textSecondary) {} FillButtonRect("Fill Rect") {} 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 index bd4a855fb7..e0b67fa2ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonStyle.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonStyle.kt @@ -52,7 +52,7 @@ interface ButtonStyle { object Slim: ButtonStyle { @Composable - override fun textStyle() = LocalType.current.extraSmall.bold() + override fun textStyle() = LocalType.current.small.bold() .copy(textAlign = TextAlign.Center) override val minHeight = 29.dp } 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 index 5fa28c633a..8e9ea6b2c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt @@ -5,7 +5,10 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.contentDescription @Composable fun CircularProgressIndicator( @@ -13,7 +16,8 @@ fun CircularProgressIndicator( color: Color = LocalContentColor.current ) { androidx.compose.material3.CircularProgressIndicator( - modifier = modifier.size(40.dp), + modifier = modifier.size(40.dp) + .contentDescription(stringResource(R.string.loading)), color = color ) } @@ -24,7 +28,8 @@ fun SmallCircularProgressIndicator( color: Color = LocalContentColor.current ) { androidx.compose.material3.CircularProgressIndicator( - modifier = modifier.size(20.dp), + modifier = modifier.size(20.dp) + .contentDescription(stringResource(R.string.loading)), color = color, strokeWidth = 2.dp ) @@ -36,7 +41,8 @@ fun ExtraSmallCircularProgressIndicator( color: Color = LocalContentColor.current ) { androidx.compose.material3.CircularProgressIndicator( - modifier = modifier.size(16.dp), + modifier = modifier.size(16.dp) + .contentDescription(stringResource(R.string.loading)), color = color, strokeWidth = 2.dp ) 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 index 8a2b3e26bd..a0454ff985 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt @@ -10,8 +10,11 @@ import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.CameraController +import androidx.camera.view.LifecycleCameraController import androidx.camera.view.PreviewView import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -38,6 +41,7 @@ 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.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource @@ -163,32 +167,42 @@ fun QRScannerScreen( @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() - - cameraProvider.get().bindToLifecycle( - LocalLifecycleOwner.current, - selector, - preview, - buildAnalysisUseCase(QRCodeReader(), onScan) - ) - - }.onFailure { Log.e(TAG, "error binding camera", it) } + val context = LocalContext.current + + // Setting up camera objects + val lifecycleOwner = LocalLifecycleOwner.current + val controller = remember { + LifecycleCameraController(context).apply { + setEnabledUseCases(CameraController.IMAGE_ANALYSIS) + setTapToFocusEnabled(true) + setPinchToZoomEnabled(true) + } + } - DisposableEffect(cameraProvider) { + DisposableEffect(Unit) { + val executor = Executors.newSingleThreadExecutor() + controller.setImageAnalysisAnalyzer(executor, QRCodeAnalyzer(QRCodeReader(), onScan)) onDispose { - cameraProvider.get().unbindAll() + controller.clearImageAnalysisAnalyzer() + executor.shutdown() } } + LaunchedEffect(controller, lifecycleOwner) { + controller.bindToLifecycle(lifecycleOwner) + controller.cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + } + + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + PreviewView(ctx).apply { + controller.let { this.controller = it } + } + } + ) + + val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -224,12 +238,24 @@ fun ScanQrCode(errors: Flow, onScan: (String) -> Unit) { } } ) { padding -> - Box { + var cachedZoom by remember { mutableStateOf(1f) } + + val zoomRange = controller.zoomState.value?.let { + it.minZoomRatio..it.maxZoomRatio + } ?: 1f..4f + + Box(Modifier.fillMaxSize() + .padding(padding)) { AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { PreviewView(it).apply { preview.setSurfaceProvider(surfaceProvider) } } + modifier = Modifier.matchParentSize(), + factory = { ctx -> + PreviewView(ctx).apply { + this.controller = controller + } + } ) + // visual cue for middle part Box( Modifier .aspectRatio(1f) @@ -238,6 +264,22 @@ fun ScanQrCode(errors: Flow, onScan: (String) -> Unit) { .background(Color(0x33ffffff)) .align(Alignment.Center) ) + + // Fullscreen overlay that captures gestures and updates camera zoom + // Without this, the bottom sheet in start-conversation, or the viewpagers + // all fight for gesture handling and the zoom doesn't work + Box( + Modifier + .matchParentSize() + .pointerInput(controller) { + detectTransformGestures { _, _, zoom, _ -> + val new = (cachedZoom * zoom) + .coerceIn(zoomRange.start, zoomRange.endInclusive) + cachedZoom = new + controller.cameraControl?.setZoomRatio(new) + } + } + ) } } } 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 index 25cd0a4bd2..39f2a57182 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt @@ -258,6 +258,7 @@ fun AnnotatedTextWithIcon( modifier: Modifier = Modifier, style: TextStyle = LocalType.current.base, color: Color = Color.Unspecified, + textQaTag: String? = null, iconSize: Pair = 12.sp to 12.sp, iconPaddingValues: PaddingValues = PaddingValues(start = style.lineHeight.value.dp * 0.2f), onIconClick: (() -> Unit)? = null @@ -296,6 +297,7 @@ fun AnnotatedTextWithIcon( modifier: Modifier = Modifier, style: TextStyle = LocalType.current.base, color: Color = Color.Unspecified, + textQaTag: String? = null, iconSize: Pair = 12.sp to 12.sp, ) { var inlineContent: Map = mapOf() @@ -330,7 +332,11 @@ fun AnnotatedTextWithIcon( Text( text = annotated, - modifier = modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth() + .then( + if (textQaTag != null) Modifier.qaTag(textQaTag) + else Modifier + ), style = style, color = color, textAlign = TextAlign.Center, diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt index 547b5e4964..21f4f410e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt @@ -44,5 +44,6 @@ data class Dimensions( val shapeMedium: Dp = 16.dp, val maxContentWidth: Dp = 410.dp, + val maxDialogWidth: Dp = 560.dp, val maxTooltipWidth: Dp = 280.dp, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ClearDataUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ClearDataUtils.kt index 08ecd450e0..b4719af079 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ClearDataUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ClearDataUtils.kt @@ -9,14 +9,11 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext -import okio.ByteString.Companion.decodeHex import org.session.libsession.messaging.notifications.TokenFetcher import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.hexEncodedPublicKey import org.thoughtcrime.securesms.ApplicationContext -import org.thoughtcrime.securesms.crypto.IdentityKeyUtil -import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.dependencies.ConfigFactory @@ -32,6 +29,7 @@ class ClearDataUtils @Inject constructor( private val storage: Storage, private val prefs: TextSecurePreferences, private val persistentLogger: PersistentLogger, + private val loginStateRepository: LoginStateRepository, ) { // Method to clear the local data - returns true on success otherwise false @SuppressLint("ApplySharedPref") @@ -49,6 +47,8 @@ class ClearDataUtils @Inject constructor( application.deleteDatabase(DatabaseMigrationManager.CIPHER4_DB_NAME) application.deleteDatabase(DatabaseMigrationManager.CIPHER3_DB_NAME) + loginStateRepository.clear() + // clear all prefs prefs.clearAll() @@ -73,18 +73,10 @@ class ClearDataUtils @Inject constructor( suspend fun clearAllDataWithoutLoggingOutAndRestart(dispatcher: CoroutineDispatcher = Dispatchers.Default) { withContext(dispatcher) { - val keyPair = storage.getUserED25519KeyPair() - if (keyPair != null) { - val x25519KeyPair = storage.getUserX25519KeyPair() - val seed = - IdentityKeyUtil.retrieve(application, IdentityKeyUtil.LOKI_SEED).decodeHex() - .toByteArray() - - clearAllData(Dispatchers.Unconfined) - KeyPairUtilities.store(application, seed, keyPair, x25519KeyPair) - prefs.setLocalNumber(x25519KeyPair.hexEncodedPublicKey) - } else { - clearAllData(Dispatchers.Unconfined) + val oldState = loginStateRepository.loggedInState.value + clearAllData(Dispatchers.Unconfined) + if (oldState != null) { + loginStateRepository.update { oldState } } delay(200) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CurrencyFormatter.kt b/app/src/main/java/org/thoughtcrime/securesms/util/CurrencyFormatter.kt new file mode 100644 index 0000000000..6aeacb40e3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CurrencyFormatter.kt @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.util + +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.NumberFormat +import java.util.Currency +import java.util.Locale + +/** + * Utility for converting and formatting prices + * to correctly localized strings. + * + * - Only supports months/years for ISO 8601 billing periods (PXM, PXY, P1Y6M) - We can add more if needed in the future + */ +object CurrencyFormatter { + + /** + * Parse only Years/Months: P1M, P3M, P1Y, P1Y6M. (Weeks/Days intentionally ignored.) + **/ + fun monthsFromIso(iso: String): Int { + val y = Regex("""(\d+)Y""").find(iso)?.groupValues?.get(1)?.toInt() ?: 0 + val m = Regex("""(\d+)M""").find(iso)?.groupValues?.get(1)?.toInt() ?: 0 + return (y * 12 + m).coerceAtLeast(1) + } + + /** + * Currency fraction digits (e.g., USD=2, JPY=0). Default to 2 if unknown. + **/ + private fun fractionDigits(code: String): Int = + Currency.getInstance(code).defaultFractionDigits.let { if (it >= 0) it else 2 } + + /** + * PRD rule: (total/months) then **ROUND DOWN** to the currency’s smallest unit. + **/ + fun perMonthUnitsFloor(totalMicros: Long, months: Int, currencyCode: String): BigDecimal { + // 1) Convert Play’s micros → currency *units* as a BigDecimal + val units = BigDecimal(totalMicros).divide(BigDecimal(1_000_000)) // e.g., 47_99_0000 → 47.99 + + // 2) Compute the raw monthly price: total / months. + // We keep extra precision (scale=10) and ROUND DOWN to avoid accidental rounding up mid-way. + val perMonth = units.divide(BigDecimal(months), 10, RoundingMode.DOWN) + + // 3) Floor to the currency’s smallest unit (fraction digits): + // USD/EUR/AUD → 2 decimals, JPY/KRW → 0 decimals, KWD → 3 decimals, etc. + return perMonth.setScale(fractionDigits(currencyCode), RoundingMode.DOWN) + } + + fun microToBigDecimal(micro: Long): BigDecimal { + return BigDecimal(micro).divide(BigDecimal(1_000_000)) + } + + /** + * Locale-correct currency formatting + **/ + fun formatUnits(amountUnits: BigDecimal, currencyCode: String, locale: Locale = Locale.getDefault()): String { + val nf = NumberFormat.getCurrencyInstance(locale) + nf.currency = Currency.getInstance(currencyCode) + return nf.format(amountUnits) + } + + /** + * Used to calculate discounts: + * floor(((baseline - plan)/baseline) * 100). Assumes both inputs already floored to fraction. + **/ + fun percentOffFloor(baselinePerMonthUnits: BigDecimal, planPerMonthUnits: BigDecimal): Int { + if (baselinePerMonthUnits <= BigDecimal.ZERO || planPerMonthUnits >= baselinePerMonthUnits) return 0 + val pct = baselinePerMonthUnits.subtract(planPerMonthUnits) + .divide(baselinePerMonthUnits, 6, RoundingMode.DOWN) + .multiply(BigDecimal(100)) + .setScale(0, RoundingMode.DOWN) + return pct.toInt() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CurrentActivityObserver.kt b/app/src/main/java/org/thoughtcrime/securesms/util/CurrentActivityObserver.kt index 3408057bd7..b4006d9fda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CurrentActivityObserver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CurrentActivityObserver.kt @@ -25,18 +25,18 @@ class CurrentActivityObserver @Inject constructor( init { application.registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} - override fun onActivityStarted(activity: Activity) { + override fun onActivityStarted(activity: Activity) {} + override fun onActivityResumed(activity: Activity) { _currentActivity.value = activity Log.d("CurrentActivityObserver", "Current activity set to: ${activity.javaClass.simpleName}") } - override fun onActivityResumed(activity: Activity) {} - override fun onActivityPaused(activity: Activity) {} - override fun onActivityStopped(activity: Activity) { + override fun onActivityPaused(activity: Activity) { if (_currentActivity.value === activity) { _currentActivity.value = null Log.d("CurrentActivityObserver", "Current activity set to null") } } + override fun onActivityStopped(activity: Activity) {} override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} override fun onActivityDestroyed(activity: Activity) {} }) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt index 4d2259e7a7..030560f41f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt @@ -200,25 +200,43 @@ class DateUtils @Inject constructor( } fun getExpiryString(instant: Instant?): String { + if (instant == null) return context.getString(R.string.proExpired) + val now = Instant.now() - val timeRemaining = Duration.between(now, instant) + val remaining = Duration.between(now, instant) - // Instant has passed - if (timeRemaining.isNegative || timeRemaining.isZero) { + // Already expired + if (remaining.isNegative || remaining.isZero) { return context.getString(R.string.proExpired) } - val totalHours = max(timeRemaining.toHours(), 1) val locale = context.resources.configuration.locales[0] val format = MeasureFormat.getInstance(locale, MeasureFormat.FormatWidth.WIDE) - return if (totalHours >= 24) { - // More than one full day remaining - show days - val days = timeRemaining.toDays() - format.format(Measure(days, MeasureUnit.DAY)) - } else { - // Less than 24 hours remaining - show hours - format.format(Measure(totalHours, MeasureUnit.HOUR)) + // Round any fractional second up to the next whole second + val totalSeconds = remaining.seconds + if (remaining.nano > 0) 1 else 0 + val DAY: Long = 86_400 + val HOUR: Long = 3_600 + val MIN: Long = 60 + + fun ceilDiv(n: Long, d: Long) = (n + d - 1) / d + + return when { + // "Days is used when there is more than 1 full day before expiry" + totalSeconds > DAY -> { + val days = ceilDiv(totalSeconds, DAY) + format.format(Measure(days, MeasureUnit.DAY)) + } + // Hours - using >= here makes exactly 1h show "1 hour" + totalSeconds >= HOUR -> { + val hours = ceilDiv(totalSeconds, HOUR) + format.format(Measure(hours, MeasureUnit.HOUR)) + } + else -> { + // Less than 1h → minutes, rounded up; ensure minimum of 1 minute + val minutes = max(1L, ceilDiv(totalSeconds, MIN)) + format.format(Measure(minutes, MeasureUnit.MINUTE)) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DonationManager.kt b/app/src/main/java/org/thoughtcrime/securesms/util/DonationManager.kt new file mode 100644 index 0000000000..e9ac3fbf4b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DonationManager.kt @@ -0,0 +1,107 @@ +package org.thoughtcrime.securesms.util + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Companion.SEEN_1 +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Companion.TRUE +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DonationManager @Inject constructor( + @param:ApplicationContext val context: Context, + val prefs: TextSecurePreferences +){ + companion object { + const val URL_DONATE = "https://getsession.org/donate#app" + } + + // increment in days between showing the donation CTA, matching the list index to the number of views of the CTA + private val donationCTADisplayIncrements = listOf(7, 3, 7, 21) + + private val maxDonationCTAViews = donationCTADisplayIncrements.size + + fun shouldShowDonationCTA(): Boolean{ + val hasDonated = getHasDonated() || getHasCopiedLink() + val seenAmount = getSeenCTAAmount() + + // return early if the user has already donated/copied the donation url + // or if they have reached the max views + if(hasDonated || seenAmount >= maxDonationCTAViews) + return false + + // if we gave a positive review and never donated, then show the donate CTA + if(getShowFromReview()) { + prefs.setShowDonationCTAFromPositiveReview(false) // reset flag + return true + } + + // display the CTA is the last is later than the increment for the current views + // the comparison point is either the last time the CTA was seen, + // or if it was never seen we check the app's install date + val comparisonDate = if(seenAmount > 0) + prefs.lastSeenDonationCTA() + else + context.packageManager.getPackageInfo(context.packageName, 0).firstInstallTime + + val elapsed = System.currentTimeMillis() - comparisonDate + val required = TimeUnit.DAYS.toMillis(donationCTADisplayIncrements[seenAmount].toLong()) + + return elapsed >= required + + } + + fun onDonationCTAViewed(){ + // increment seen amount + prefs.setSeenDonationCTAAmount(prefs.seenDonationCTAAmount() + 1) + // set seen time + prefs.setLastSeenDonationCTA(System.currentTimeMillis()) + } + + fun onDonationSeen(){ + prefs.setHasDonated(true) + } + + fun onDonationCopied(){ + prefs.setHasCopiedDonationURL(true) + } + + private fun getHasDonated(): Boolean{ + val debug = prefs.hasDonatedDebug() + return if(debug != null){ + when(debug){ + TRUE -> true + else -> false + } + } else prefs.hasDonated() + } + + private fun getHasCopiedLink(): Boolean{ + val debug = prefs.hasCopiedDonationURLDebug() + return if(debug != null){ + when(debug){ + TRUE -> true + else -> false + } + } else prefs.hasCopiedDonationURL() + } + + private fun getSeenCTAAmount(): Int{ + val debug = prefs.seenDonationCTAAmountDebug() + return if(debug != null){ + debug.toInt() + } else prefs.seenDonationCTAAmount() + } + + private fun getShowFromReview(): Boolean{ + val debug = prefs.showDonationCTAFromPositiveReviewDebug() + return if(debug != null){ + when(debug){ + TRUE -> true + else -> false + } + } else prefs.showDonationCTAFromPositiveReview() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/UserProfileUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/UserProfileUtils.kt index 8b09a6a485..c4df000fbf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/UserProfileUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/UserProfileUtils.kt @@ -23,12 +23,12 @@ import org.session.libsession.utilities.isBlinded import org.session.libsession.utilities.isCommunityInbox import org.session.libsession.utilities.recipients.RecipientData import org.session.libsession.utilities.recipients.displayName -import org.session.libsession.utilities.recipients.isPro -import org.session.libsession.utilities.recipients.shouldShowProBadge import org.session.libsession.utilities.toBlinded import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.BlindMappingRepository import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.pro.ProStatus +import org.thoughtcrime.securesms.pro.ProStatusManager /** * Helper class to get the information required for the user profile modal @@ -41,6 +41,7 @@ class UserProfileUtils @AssistedInject constructor( private val avatarUtils: AvatarUtils, private val blindedIdMappingRepository: BlindMappingRepository, private val recipientRepository: RecipientRepository, + private val proStatusManager: ProStatusManager ) { private val _userProfileModalData: MutableStateFlow = MutableStateFlow(null) val userProfileModalData: StateFlow @@ -113,8 +114,8 @@ class UserProfileUtils @AssistedInject constructor( name = if (recipient.isLocalNumber) context.getString(R.string.you) else recipient.displayName(), subtitle = (recipient.data as? RecipientData.Contact)?.nickname?.takeIf { it.isNotBlank() }?.let { "($it)" }, avatarUIData = avatarUtils.getUIDataFromRecipient(recipient), - showProBadge = recipient.proStatus.shouldShowProBadge(), - currentUserPro = recipientRepository.getSelf().proStatus.isPro(), + showProBadge = recipient.shouldShowProBadge, + currentUserPro = recipientRepository.getSelf().isPro, rawAddress = recipient.address.address, displayAddress = displayAddress, threadAddress = threadAddress, @@ -123,7 +124,7 @@ class UserProfileUtils @AssistedInject constructor( enableMessage = !recipient.address.isBlinded || recipient.acceptsBlindedCommunityMessageRequests, expandedAvatar = false, showQR = false, - showProCTA = false, + showProCTA = null, messageAddress = messageAddress, ) @@ -140,11 +141,15 @@ class UserProfileUtils @AssistedInject constructor( fun onCommand(command: UserProfileModalCommands){ when(command){ UserProfileModalCommands.ShowProCTA -> { - _userProfileModalData.update { _userProfileModalData.value?.copy(showProCTA = true) } + _userProfileModalData.update { + _userProfileModalData.value?.copy( + showProCTA = GenericCTAData(proStatusManager.proDataState.value.type) + ) + } } UserProfileModalCommands.HideSessionProCTA -> { - _userProfileModalData.update { _userProfileModalData.value?.copy(showProCTA = false) } + _userProfileModalData.update { _userProfileModalData.value?.copy(showProCTA = null) } } UserProfileModalCommands.ToggleQR -> { @@ -196,7 +201,11 @@ data class UserProfileModalData( val expandedAvatar: Boolean, val showQR: Boolean, val avatarUIData: AvatarUIData, - val showProCTA: Boolean + val showProCTA: GenericCTAData? +) + +data class GenericCTAData( + val proSubscription: ProStatus ) sealed interface UserProfileModalCommands { 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 7b965f2e3c..9703ab1bf1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt @@ -154,6 +154,7 @@ fun View.applySafeInsetsMargins( consumeInsets: Boolean = true, @InsetsType typeMask: Int = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime(), + additionalInsets : Insets = Insets.NONE // for additional offsets ) { ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> // Get system bars insets @@ -162,7 +163,7 @@ fun View.applySafeInsetsMargins( // Update view margins to account for system bars val lp = view.layoutParams as? MarginLayoutParams if (lp != null) { - lp.setMargins(systemBarsInsets.left, systemBarsInsets.top, systemBarsInsets.right, systemBarsInsets.bottom) + lp.setMargins(additionalInsets.left + systemBarsInsets.left, additionalInsets.top + systemBarsInsets.top, additionalInsets.right + systemBarsInsets.right, additionalInsets.bottom + systemBarsInsets.bottom) view.layoutParams = lp if (consumeInsets) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt index 5a864b0f80..168e46276d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -74,6 +74,7 @@ class CallManager @Inject constructor( @param:ManagerScope private val scope: CoroutineScope, audioManager: AudioManagerCompat, private val storage: StorageProtocol, + private val messageSender: MessageSender, ): PeerConnection.Observer, SignalAudioManager.EventListener, CameraEventListener, DataChannel.Observer { @@ -340,7 +341,7 @@ class CallManager @Inject constructor( .also { scope.launch { runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( it, currentRecipient, isSyncMessage = currentRecipient.isLocalNumber @@ -491,7 +492,7 @@ class CallManager @Inject constructor( Log.i("Loki", "Posting new answer") runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( answerMessage, recipient, isSyncMessage = recipient.isLocalNumber @@ -545,7 +546,7 @@ class CallManager @Inject constructor( val userAddress = storage.getUserPublicKey() ?: throw NullPointerException("No user public key") runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( answerMessage, Address.fromSerialized(userAddress), isSyncMessage = true @@ -553,7 +554,7 @@ class CallManager @Inject constructor( } runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( CallMessage.answer( answer.description, callId @@ -609,7 +610,7 @@ class CallManager @Inject constructor( Log.d("Loki", "Sending pre-offer") try { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( CallMessage.preOffer( callId ).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber @@ -619,7 +620,7 @@ class CallManager @Inject constructor( Log.d("Loki", "Sending offer") postViewModelState(CallViewModel.State.CALL_OFFER_OUTGOING) - MessageSender.sendNonDurably(CallMessage.offer( + messageSender.sendNonDurably(CallMessage.offer( offer.description, callId ).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber) @@ -639,7 +640,7 @@ class CallManager @Inject constructor( stateProcessor.processEvent(Event.DeclineCall) { scope.launch { runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( CallMessage.endCall(callId).applyExpiryMode(recipient), Address.fromSerialized(userAddress), isSyncMessage = true @@ -648,7 +649,7 @@ class CallManager @Inject constructor( } scope.launch { runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( CallMessage.endCall(callId).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber @@ -680,7 +681,7 @@ class CallManager @Inject constructor( scope.launch { runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( CallMessage.endCall(callId).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber @@ -919,7 +920,7 @@ class CallManager @Inject constructor( connection.setLocalDescription(offer) scope.launch { runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( CallMessage.offer(offer.description, callId).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber diff --git a/app/src/main/proto/SignalService.proto b/app/src/main/proto/SignalService.proto index 72e8bb739b..dab5289377 100644 --- a/app/src/main/proto/SignalService.proto +++ b/app/src/main/proto/SignalService.proto @@ -59,6 +59,8 @@ message Content { optional ExpirationType expirationType = 12; optional uint32 expirationTimerSeconds = 13; optional uint64 sigTimestampMs = 15; + optional ProMessage proMessage = 16; + optional bytes proSigForCommunityMessageOnly = 17; reserved 14; reserved 11; // Used to be a "sharedConfigMessage" but no longer used @@ -313,4 +315,18 @@ message AttachmentPointer { optional uint32 height = 10; optional string caption = 11; optional string url = 101; +} + +message ProMessage { + optional ProProof proof = 1; + optional uint64 profile_bitset = 2; + optional uint64 msg_bitset = 3; +} + +message ProProof { + optional uint32 version = 1; + optional bytes genIndexHash = 2; + optional bytes rotatingPublicKey = 3; + optional uint64 expiryUnixTs = 4; // Epoch timestamp in milliseconds + optional bytes sig = 5; } \ No newline at end of file diff --git a/app/src/main/res/drawable/cta_hero_animated_bg.webp b/app/src/main/res/drawable/cta_hero_animated_bg.webp index 9d2ee88e15..d6571612a7 100644 Binary files a/app/src/main/res/drawable/cta_hero_animated_bg.webp and b/app/src/main/res/drawable/cta_hero_animated_bg.webp differ diff --git a/app/src/main/res/drawable/cta_hero_char_limit.webp b/app/src/main/res/drawable/cta_hero_char_limit.webp index 87e7e3e56f..12e3118006 100644 Binary files a/app/src/main/res/drawable/cta_hero_char_limit.webp and b/app/src/main/res/drawable/cta_hero_char_limit.webp differ diff --git a/app/src/main/res/drawable/cta_hero_flower.webp b/app/src/main/res/drawable/cta_hero_flower.webp new file mode 100644 index 0000000000..b7fbe5ae9e Binary files /dev/null and b/app/src/main/res/drawable/cta_hero_flower.webp differ diff --git a/app/src/main/res/drawable/cta_hero_generic_bg.webp b/app/src/main/res/drawable/cta_hero_generic_bg.webp index f4d150ea6a..2780c953ce 100644 Binary files a/app/src/main/res/drawable/cta_hero_generic_bg.webp and b/app/src/main/res/drawable/cta_hero_generic_bg.webp differ diff --git a/app/src/main/res/drawable/cta_hero_pins.webp b/app/src/main/res/drawable/cta_hero_pins.webp index 8a5070fb59..9fb34151e5 100644 Binary files a/app/src/main/res/drawable/cta_hero_pins.webp and b/app/src/main/res/drawable/cta_hero_pins.webp differ diff --git a/app/src/main/res/drawable/ic_circle_help.xml b/app/src/main/res/drawable/ic_circle_help.xml index 3b3b13768b..1fba16ac74 100644 --- a/app/src/main/res/drawable/ic_circle_help.xml +++ b/app/src/main/res/drawable/ic_circle_help.xml @@ -1,4 +1,4 @@ - + diff --git a/app/src/main/res/drawable/ic_question_custom.xml b/app/src/main/res/drawable/ic_question_custom.xml index 148e732324..1f9cfc20e0 100644 --- a/app/src/main/res/drawable/ic_question_custom.xml +++ b/app/src/main/res/drawable/ic_question_custom.xml @@ -1,4 +1,4 @@ - + diff --git a/app/src/main/res/drawable/ic_square_play.xml b/app/src/main/res/drawable/ic_square_play.xml index ddec1f8d6c..e69e5cbc11 100644 --- a/app/src/main/res/drawable/ic_square_play.xml +++ b/app/src/main/res/drawable/ic_square_play.xml @@ -1,4 +1,4 @@ - + diff --git a/app/src/main/res/drawable/state_list_ic_mic.xml b/app/src/main/res/drawable/state_list_ic_mic.xml new file mode 100644 index 0000000000..3044ce5cec --- /dev/null +++ b/app/src/main/res/drawable/state_list_ic_mic.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/state_list_ic_video.xml b/app/src/main/res/drawable/state_list_ic_video.xml new file mode 100644 index 0000000000..10170edc1f --- /dev/null +++ b/app/src/main/res/drawable/state_list_ic_video.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_webrtc.xml b/app/src/main/res/layout/activity_webrtc.xml index 77c2cc34c0..6af9ac9e20 100644 --- a/app/src/main/res/layout/activity_webrtc.xml +++ b/app/src/main/res/layout/activity_webrtc.xml @@ -180,7 +180,7 @@ android:id="@+id/enableCameraButton" android:background="@drawable/call_controls_background" app:tint="@color/state_list_call_action_foreground" - android:src="@drawable/ic_video" + android:src="@drawable/state_list_ic_video" android:padding="@dimen/medium_spacing" android:layout_width="@dimen/large_button_height" android:layout_height="@dimen/large_button_height" @@ -195,7 +195,7 @@ android:layout_width="@dimen/large_button_height" android:layout_height="@dimen/large_button_height" android:padding="@dimen/medium_spacing" - android:src="@drawable/ic_mic_off" + android:src="@drawable/state_list_ic_mic" android:layout_marginBottom="@dimen/large_spacing" app:layout_constraintBottom_toTopOf="@+id/endCallButton" android:background="@drawable/call_controls_mic_background" diff --git a/app/src/main/res/layout/typing_indicator_preference.xml b/app/src/main/res/layout/typing_indicator_preference.xml new file mode 100644 index 0000000000..a7754d36b3 --- /dev/null +++ b/app/src/main/res/layout/typing_indicator_preference.xml @@ -0,0 +1,23 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-b+az+AZ/strings.xml b/app/src/main/res/values-b+az+AZ/strings.xml index 146b346fa7..a6ad5d2716 100644 --- a/app/src/main/res/values-b+az+AZ/strings.xml +++ b/app/src/main/res/values-b+az+AZ/strings.xml @@ -15,15 +15,19 @@ Bu, sizin Hesab Kimliyinizdir. Digər istifadəçilər onu skan edərək sizinlə danışıq başlada bilər. Aktual həcmi Əlavə et - İnzibatçılar əlavə et + + Admin əlavə et + Adminləri əlavə et + Admin edəcəyiniz istifadəçinin Hesab ID-sini daxil edin.\n\nBirdən çox istifadəçi əlavə etmək üçün vergüllə ayrılmış hər Hesab ID-sini daxil edin. Bir dəfəyə 20-yə qədər Hesab ID-si daxil edilə bilər. - Adminlər çıxarıla bilməz. + Adminlərin vəzifəsini azaltmaq və ya adminləri qrupdan xaric etmək mümkün deyil. + Adminlər xaric edilə bilməz. {name}başqa {count} nəfər Admin olaraq yüksəldildi. Adminləri yüksəlt - {name} istifadəçisini admin etmək istədiyinizə əminsiniz? Adminlər çıxarıla bilməz. - {name}başqa {count} nəfəri admin etmək istədiyinizə əminsiniz? Adminlər çıxarıla bilməz. + {name} istifadəçisini admin etmək istədiyinizə əminsiniz? Adminlər xaric edilə bilməz. + {name}başqa {count} nəfəri admin etmək istədiyinizə əminsiniz? Adminlər xaric edilə bilməz. Admin olaraq yüksəlt - {name}{other_name} admin etmək istədiyinizə əminsiniz? Adminlər silinə bilməz. + {name}{other_name} istifadəçilərini admin etmək istədiyinizə əminsiniz? Adminlər xaric edilə bilməz. {name} Admin olaraq yüksəldildi. Admin təyinatı uğursuz oldu {name} istifadəçisini {group_name} qrupunda yüksəltmə uğursuz oldu @@ -32,21 +36,26 @@ Yüksəltmə göndərilmədi Admin təyinatı göndərildi Yüksəltmə statusu bilinmir - Adminləri çıxart - Admin olaraq sil + Adminləri xaric et + Admin olaraq xaric et Bu İcmada heç bir Admin yoxdur. - {name} istifadəçisini Admin olaraq çıxartma uğursuz oldu. - {name}digər {count} nəfər Adminlikdən çıxarılmadı. - {name}{other_name} Adminlikdən çıxarılmadı. - {name} artıq Admin deyil. - {name}digər {count} nəfər Adminlikdən çıxarıldı. - {name}{other_name} Adminlikdən çıxarıldı. + {name} Adminlikdən xaric edilmədi. + {name}digər {count} nəfər Adminlikdən xaric edilmədi. + {name}{other_name} Adminlikdən xaric edilmədi. + {name} Adminlikdən xaric edildi. + {name}digər {count} nəfər Adminlikdən xaric edildi. + {name}{other_name} Adminlikdən xaric edildi. + + %1$d Admin seçildi + %1$d Admin seçildi + Admin təyinatı göndərilir Admin təyinatları göndərilir Admin ayarları {name}{other_name} Admin olaraq yüksəldildi. + Adminlər +{count} Anonim Tətbiq ikonu @@ -113,7 +122,7 @@ Fayl ölçüsü: Fayl növü: Bu danışıqda heç bir faylınız yoxdur. - Meta veri, fayldan silinə bilmir. + Meta veri, fayldan xaric edilə bilmir. Yeni media yüklənir... Yeni fayllar yüklənir... Köhnə media yüklənir... @@ -191,6 +200,9 @@ Digər istifadəçilərlə səsli və görüntülü zəngləri təmin edir. {name} istifadəçisinə zəng etdiniz Gizlilik Ayarlarında Səsli və görüntülü zənglər seçimini fəallaşdırmadığınız üçün {name} etdiyi bir zəngi buraxdınız. + Kamera erişiminə icazə vermək üçün ayarları aç və Kamera icazəsini işə sal. + Son zəng zamanı, görüntünü istifadə etməyə çalışdınız, ancaq kamera erişiminə daha əvvəl rədd cavabı verildiyi üçün edə bilmədiniz. Kamera erişiminə icazə vermək üçün ayarları açıb Kamera icazəsini işə salın. + Kamera erişimi tələb olunur Kamera tapılmadı Kamera əlçatmazdır. Kamera erişiminə icazə ver @@ -198,12 +210,12 @@ {app_name} foto və video çəkmək və ya QR kodlarını skan etmək üçün kameraya müraciət etməlidir. {app_name} QR kodlarını skan etmək üçün kameraya müraciət etməlidir İmtina - Planı ləğv et + {pro} - ləğv et Dəyişdir Parol dəyişdirmə uğursuz oldu {app_name} üçün parolunuzu dəyişdirin. Daxili olaraq saxlanılmış verilər, yeni parolunuzla təkrar şifrələnəcək. {pro} statusu yoxlanılır - {pro} məlumatlarınız yoxlanılır. Bu səhifədəki bəzi məlumatlar, yoxlama tamamlanana qədər qeyri-dəqiq ola bilər. + {pro} statusu yoxlanılır... {pro} statusunuz yoxlanılır. Bu yoxlama tamamlandıqdan sonra {pro} ya yüksəldə biləcəksiniz. Təmizlə Hamısını təmizlə @@ -263,11 +275,17 @@ İcma URL-si İcma URL-sini kopyala Təsdiqlə + Yüksəltməni təsdiqlə + Əminsiniz? Adminlərin vəzifəsini azaltmaq və ya adminləri qrupdan xaric etmək mümkün deyil. Kontaktlar Kontaktı sil {name} istifadəçisini silmək istədiyinizə əminsiniz? {name} göndərən yeni mesajlar mesaj tələbi olaraq gələcək. Hələ heç bir kontaktınız yoxdur Kontaktları seç + + %1$d Kontakt seçildi + %1$d Kontakt seçildi + İstifadəçi detalları Kamera Danışıq başlatmaq üçün bir fəaliyyət seçin @@ -308,8 +326,8 @@ Kopyala Yarat Zəng yaradılır + Hazırkı faktura Hazırkı parol - Hazırkı plan Kəs Qaranlıq rejim Bu cihazdan bütün mesajları, qoşmaları və hesab məlumatlarını silmək və yeni hesab yaratmaq istədiyinizə əminsinizmi? @@ -336,6 +354,14 @@ Qrup yaradılarkən lütfən gözləyin... Qrup güncəlləmə uğursuz oldu Başqalarının mesajlarını silmə icazəniz yoxdur + + Seçilmiş qoşmanı sil + Seçilmiş qoşmaları sil + + + Seçilmiş qoşmanı silmək istədiyinizə əminsiniz? Qoşma ilə əlaqəli mesaj da silinəcək. + Seçilmiş qoşmaları silmək istədiyinizə əminsiniz? Qoşmalarla əlaqəli mesaj da silinəcək. + {name} adlı şəxsi kontaktlarınızdan silmək istədiyinizə əminsinizmi?\n\nBu, bütün mesajlar və qoşmalar daxil olmaqla söhbətinizi siləcək. {name} ünvanından gələcək mesajlar mesaj sorğusu kimi görünəcək. {name} ilə söhbətinizi silmək istədiyinizə əminsinizmi?\nBu, bütün mesajları və qoşmaları həmişəlik siləcək. @@ -451,19 +477,28 @@ Siz və {name}, {emoji_name} ilə reaksiya verdiniz Mesajınıza {emoji} reaksiyasını verdi Fəallaşdır + Kamera erişimi fəallaşdırılsın? Yeni mesaj aldığınız zaman bildirişlər göstərilsin. + Fəallaşdırmaq üçün zəngi söndür {app_name}-dan zövq alırsınız? Təkmilləşməlidir {emoji} Əladır {emoji} {app_name} tətbiqini bir müddətdir istifadə edirsiniz, necə gedir? Fikirlərinizi eşitmək bizim üçün çox dəyərli olardı. Daxil ol + {app_name} üçün təyin etdiyiniz parolu daxil edin {pro} statusunu yoxlama xətası. Lütfən internet bağlantınızı yoxlayıb yenidən sınayın. Xətanı kopyala və çıx Veri bazası xətası Nəsə səhv getdi. Lütfən daha sonra yenidən sınayın. - {pro} planını yükləmə xətası. + {pro} erişimini yükləmə xətası + {app_name}, bu ONS-ni axtara bilmədi. Lütfən şəbəkə bağlantınızı yoxlayıb yenidən sınayın. Bilinməyən bir xəta baş verdi. + Bu ONS qeydiyyatdan keçməyib. Lütfən doğru olub-olmadığını yoxlayıb yenidən sınayın. + {group_name} qrupundakı {name} üçün dəvət təkrar göndərilmədi + {group_name} qrupundakı {name}digər {count} nəfər üçün dəvət təkrar göndərilmədi + {group_name} qrupundakı {name}{other_name} üçün dəvət təkrar göndərilmədi + {group_name} qrupundakı {name}{other_name} üçün dəvət təkrar göndərilmədi Endirmək uğursuz oldu Xətalar Əks-əlaqə @@ -483,7 +518,7 @@ Qrup yarat Lütfən ən azı bir qrup üzvü seçin. Qrupu sil - {group_name} qrupunu silmək istədiyinizdən əminsinizmi?\n\nBu, bütün üzvləri çıxaracaq və bütün qrup məzmununu siləcək. + {group_name} qrupunu silmək istədiyinizə əminsiniz?\n\nBu, bütün üzvləri xaric edəcək və qrupun bütün məzmununu siləcək. {group_name} qrupunu silmək istədiyinizə əminsiniz? {group_name}, qrup admini tərəfindən silindi. Artıq mesaj göndərə bilməyəcəksiniz. Qrup açıqlamasını daxil edin @@ -515,8 +550,11 @@ Siz qrupa qoşulmaq üçün dəvət edildiniz. Söhbət tarixçəsi paylaşıldı. Qrupu tərk et {group_name} qrupunu tərk etmək istədiyinizə əminsiniz? - {group_name} qrupunu tərk etmək istədiyinizə əminsiniz?\n\nBununla bütün üzvləri çıxarılacaq və qrupun bütün məzmunu silinəcək. + {group_name} qrupunu tərk etmək istədiyinizə əminsiniz?\n\nBu, bütün üzvlər xaric ediləcək və qrupun bütün məzmunu silinəcək. {group_name} qrupunu tərk etmə uğursuz oldu + {name} qrupa qoşulmaq üçün dəvət edildi. Son 14 günə aid söhbət tarixçəsi paylaşıldı. + {name} və digər {count} nəfər qrupa qoşulmaq üçün dəvət edildi. Son 14 günə aid söhbət tarixçəsi paylaşıldı. + {name}{other_name} qrupa qoşulmaq üçün dəvət edildi. Son 14 günə aid söhbət tarixçəsi paylaşıldı. {name} qrupu tərk etdi. {name}başqa {count} nəfər qrupu tərk etdi. {name}{other_name} qrupu tərk etdi. @@ -528,6 +566,9 @@ {name}{other_name} qrupa qoşulmaq üçün dəvət edildi. Sizdigər {count} nəfər qrupa qoşulmaq üçün dəvət edildiniz. Söhbət tarixçəsi paylaşıldı. Siz{other_name} qrupa qoşulmaq üçün dəvət edildiniz. Söhbət tarixçəsi paylaşıldı. + {name} {group_name} qrupundan xaric edilmədi + {name}digər {count} nəfər {group_name} qrupundan xaric edilmədi + {name}{other_name} {group_name} qrupundan xaric edilmədi Siz qrupu tərk etdiniz. Qrup üzvləri Bu qrupda başqa üzv yoxdur. @@ -541,28 +582,29 @@ {group_name} daxilində heç bir mesajınız yoxdur. Danışığa başlamaq üçün bir mesaj göndərin! Bu qrup 30 gün ərzində güncəllənməyib. Mesaj göndərərkən və ya qrup məlumatlarına baxarkən problemlərlə üzləşə bilərsiniz. Siz {group_name} qrupunda yeganə adminsiniz.\n\nQrup üzvləri və ayarları admin olmadan dəyişdirilə bilməz. + Siz, {group_name} qrupunun yeganə adminisiniz.\n\nQrup üzvləri və ayarları admin olmadan dəyişdirilə bilməz. Qrupu silmədən tərk etmək üçün, əvvəlcə yeni bir admin əlavə edin Silmə gözlənilir Siz Admin oldunuz. Siz digər {count} nəfər Admin oldunuz. Siz{other_name} Admin oldunuz. - {name} istifadəçisini {group_name} qrupundan çıxarmaq istəyirsiniz? - {name} və digər {count} nəfəri {group_name} qrupundan çıxartmaq istəyirsiniz? - {name}{other_name} istifadəçilərini {group_name} qrupundan çıxarmaq istəyirsiniz? + {name} istifadəçisini {group_name} qrupundan xaric etmək istəyirsiniz? + {name} və digər {count} nəfəri {group_name} qrupundan xaric etmək istəyirsiniz? + {name}{other_name} istifadəçilərini {group_name} qrupundan xaric etmək istəyirsiniz? - İstifadəçini və mesajlarını sil - İstifadəçiləri və mesajlarını sil + İstifadəçini xaric et və mesajlarını sil + İstifadəçiləri xaric et və mesajlarını sil - İstifadəçini sil - İstifadəçiləri sil + İstifadəçini xaric et + İstifadəçiləri xaric et - {name} qrupdan çıxarıldı. - {name}başqa {count} nəfər qrupdan çıxarıldı. - {name}{other_name} qrupdan çıxarıldı. - {group_name} qrupundan çıxarıldınız. - Qrupdan çıxarıldınız. - Sizdigər {count} nəfər qrupdan çıxarıldınız. - Siz{other_name} qrupdan çıxarıldınız. + {name} qrupdan xaric edildi. + {name}digər {count} nəfər qrupdan xaric edildi. + {name}{other_name} qrupdan xaric edildi. + {group_name} qrupundan xaric edildiniz. + Qrupdan xaric edildiniz. + Sizdigər {count} nəfər qrupdan xaric edildiniz. + Siz{other_name} qrupdan xaric edildiniz. Qrup ekran şəklini ayarla Bilinməyən Qrup Qrup güncəlləndi @@ -590,6 +632,10 @@ Mövcuddursa gizli rejimi tələb et. İstifadə etdiyiniz klaviaturadan asılı olaraq, klaviaturanız bu tələbi yox saya bilər. Məlumat Yararsız qısayol + + Kontaktı dəvət et + Kontaktları dəvət et + Dəvət uğursuz oldu Dəvətlər uğursuz oldu @@ -598,6 +644,11 @@ Dəvət göndərilə bilmədi. Təkrar cəhd etmək istəyirsiniz? Dəvətlər göndərilə bilmədi. Təkrar cəhd etmək istəyirsiniz? + + Üzvü dəvət et + Üzvləri dəvət et + + Dostunuzun Hesab kimliyini, ONS-sini daxil edərək və ya onun QR kodunu skan edərək qrupa yeni üzv dəvət edin {icon} Qoşul Daha sonra Kompüteriniz açıldığı zaman {app_name}-u avtomatik başlat. @@ -641,10 +692,15 @@ Kilidi açmaq üçün toxunun {app_name} kilidi açıldı Log-lar + Adminləri idarə et Üzvləri İdarə Et {pro} - idarə et Maksimum Media + + %1$d üzv seçildi + %1$d üzv seçildi + %1$d üzv %1$d üzv @@ -655,6 +711,7 @@ Hesab Kimliyi və ya ONS əlavə et Kontaktları dəvət et + Bu qrupa dəvət edəcək kontaktınız yoxdur.\nGeri qayıdın və Hesab kimliyini və ya ONS-sini istifadə edərək üzvlər dəvət edin. Dəvət göndər Dəvətləri göndər @@ -665,6 +722,7 @@ Mesaj tarixçəsini paylaş Yalnız yeni mesajları paylaş Dəvət et + Üzvlər (admin olmayanlar) Menyu çubuğu Mesaj Daha çox oxu @@ -683,6 +741,7 @@ Yeni bir danışıq başlatmaq üçün dostunuzun Hesab Kimliyini və ya ONS-sini daxil edin. Yeni bir danışıq başlatmaq üçün dostunuzun Hesab Kimliyini, ONS-sini daxil edin və ya onun QR kodunu skan edin. + Yeni bir danışıq başlatmaq üçün dostunuzun Hesab Kimliyini, ONS-sini daxil edin və ya onun QR kodunu skan edin {icon} Yeni bir mesajınız var. %1$d yeni mesajınız var. @@ -740,7 +799,10 @@ Ləqəbi sil Ləqəb təyin et Xeyr + Bu qrupda admin olmayan üzv yoxdur. Təklif yoxdur + Bütün danışıqlarda 10,000 xarakterə qədər mesaj göndərin. + Limitsiz sancılmış danışıqla söhbətləri təşkil edin. Heç biri İndi yox Özümə qeyd @@ -791,7 +853,7 @@ Yaxşı Açıq {device_type} cihazınızda - Başda qeydiyyatdan keçdiyiniz {platform_account} hesabına giriş etdiyiniz {device_type} cihazından bu {app_name} hesabını açın. Sonra planınızı {app_pro} ayarları vasitəsilə dəyişdirin. + Başda qeydiyyatdan keçdiyiniz {platform_account} hesabına giriş etdiyiniz {device_type} cihazından bu {app_name} hesabını açın. Sonra {pro} erişiminizi {app_pro} ayarları vasitəsilə güncəlləyin. Hesab yarat Hesab yaradıldı Hesabım var @@ -816,7 +878,8 @@ Bu ONS-i tanıya bilmədik. Lütfən, yoxlayıb yenidən sınayın. Bu ONS-i axtara bilmədik. Lütfən, daha sonra yenidən sınayın. - {platform_store} veb saytını aç + {platform} veb saytını aç + Ayarları aç Anketi aç Digər Parol @@ -893,12 +956,28 @@ Tərcihlər Önizləmə Bildirişi önizlə + {pro} erişiminiz aktivdir!\n\n{pro} erişiminiz avtomatik olaraq başqa {current_plan_length} üçün {date} tarixində yenilənəcək. + {pro} erişiminizin müddəti {date} tarixində bitir.\n\n{pro} erişiminizin istifadə müddəti bitməzdən əvvəl avtomatik olaraq yenilənməsi üçün {pro} erişiminizi indi güncəlləyin. + {pro} erişiminiz aktivdir!\n\n{pro} erişiminiz avtomatik olaraq başqa \n{current_plan_length} üçün {date} tarixində yenilənəcək. Burada etdiyiniz istənilən dəyişiklik, növbəti yeniləmə zamanı qüvvəyə minəcək. + {pro} erişim xətası + {pro} erişiminizin istifadə müddəti {date} tarixində bitir. + {pro} erişimi yüklənir + {pro} erişimi yüklənir... + {pro} erişimi tapılmadı + {app_name}, hesabınızda {pro} erişimi olmadığını müəyyən etdi. Bunun bir səhv olduğunu düşünürsünüzsə, lütfən kömək üçün {app_name} dəstəyi ilə əlaqə saxlayın. + {pro} erişimini geri qaytar + {pro} erişimini yenilə + Hazırda, {pro} erişimi , yalnız {platform_store} və {platform_store_other} vasitəsilə satın alına və yenilənə bilər. {app_name} Masaüstü istifadə etdiyinizə görə burada yeniləyə bilməzsiniz.\n\n{app_name} gəlişdiriciləri, istifadəçilərin {pro} erişimini {platform_store} və {platform_store_other} xaricində almağına imkan verəcək alternativ ödəniş variantları üzərində ciddi şəkildə çalışırlar. {pro} Yol Xəritəsi {icon} + {pro} erişiminizi {pro} üçün qeydiyyatdan keçdiyiniz {platform_account} istifadə edərək {platform_store} veb saytında yeniləyin. + Güclü {pro} Beta özəlliklərini yenidən istifadə etməyə başlamaq üçün {app_pro} erişiminizi yeniləyin. + {pro} erişimi geri qaytarıldı + {app_name}, hesabınız üçün {pro} erişimini aşkarladı və geri qaytardı. {pro} statusunuz bərpa edildi! + Başlanğıcda {platform_store} üzərindən {app_pro} üçün qeydiyyatdan keçdiyinizə görə, {pro} erişiminizi {platform_account} istifadə edərək güncəlləməlisiniz. Aktivləşdirildi Hər şey hazırdır! - {app_pro} planınız güncəlləndi! Hazırkı {pro} planınız avtomatik olaraq {date} tarixində yeniləndiyi zaman ödəniş haqqı alınacaq. + {app_pro} erişiminiz güncəllənib! {pro} abunəliyiniz {date} tarixində avtomatik yeniləndiyi zaman ödəniş haqqı alınacaq. Artıq yüksəltdiniz Getdik və ekran şəkliniz üçün GIF-lər və animasiyalı WebP təsvirləri yükləyin! - {app_pro} ilə animasiyalı ekran şəkillərini endirin və premium özəlliklərin kilidini açın Animasiyalı profil şəkli istifadəçiləri GIF-ləri yükləyə bilər Animasiyalı ekran şəkilləri @@ -917,15 +996,13 @@ {price} - illik haqq {price} - aylıq haqq {price} - rüblük haqq - Daha uzun mesajlar göndərmək istəyirsiniz? {app_pro} ilə daha çox mətn göndərin və premium özəlliklərin kilidini açın - Daha çoxunu sancmaq istəyirsiniz? {app_pro} ilə söhbətlərinizi təşkil edin və premium özəlliklərin kilidini açın - 5-dən çoxunu sancmaq istəyirsiniz? {app_pro} ilə söhbətlərinizi təşkil edin və premium özəlliklərin kilidini açın - Hazırkı planınızda artıq tam {app_pro} qiymətinin {percent}% endirimi mövcuddur. + Ləğv etmə + {pro} erişiminizi ləğv etməyin iki yolu var: + Tam {app_pro} qiymətinə sahib {pro} erişiminiz üçün artıq {percent}% endirim var. {pro} statusunu təzələmə xətası Müddəti bitib - Təəssüf ki, {pro} planınızın müddəti bitib. {app_pro} tətbiqinin eksklüziv imtiyazlarına və özəlliklərinə erişimi davam etdirmək üçün yeniləyin. + Təəssüf ki, {pro} erişiminizin istifadə müddəti bitib. {app_pro} üzrə eksklüziv imtiyazları və özəllikləri təkrar aktivləşdirmək üçün yeniləyin. Tezliklə bitir - {pro} planınızın müddəti {time} vaxtında bitir. {app_pro} tətbiqinin eksklüziv imtiyazlarına və özəlliklərinə erişimi davam etdirmək üçün planınızı güncəlləyin. {pro}, {time} vaxtında başa çatır {pro} TVS {app_pro} TVS-da tez-tez verilən suallara cavab tapın. @@ -940,11 +1017,12 @@ %1$s qrup yüksəldildi %1$s qrup yüksəldildi - Geri ödəniş tələbi qətidir. Əgər təsdiqlənsə, {pro} planınız dərhal ləğv ediləcək və bütün {pro} özəlliklərinə erişimi itirəcəksiniz. + Geri ödəniş tələbi qətidir. Əgər təsdiqlənsə, {pro} erişiminiz dərhal ləğv ediləcək və bütün {pro} özəlliklərinə erişimi itirəcəksiniz. Artırılmış qoşma ölçüsü Artırılmış mesaj uzunluğu Daha böyük qruplar Admin olduğunuz qruplar, avtomatik olaraq 300 üzvü dəstəkləmək üçün təkmilləşdirilir. + Tezliklə bütün Pro Beta istifadəçiləri üçün daha böyük qrup söhbətləri (300 üzvə qədər) gəlir! Daha uzun mesajlar Bütün danışıqlarda 10,000 xarakterə qədər mesaj göndərə bilərsiniz. @@ -952,44 +1030,30 @@ %1$s daha uzun mesaj göndərildi Bu mesajda aşağıdakı {app_pro} özəllikləri istifadə olunub: + İndilik, yeniləməyin iki yolu var: {percent}% endirim %1$s sancılmış danışıq %1$s sancılmış danışıq - {app_pro} planınız aktivdir!\n\nPlanınız avtomatik olaraq {date} tarixində başqa bir {current_plan} üçün yenilənəcək. Planınıza edilən güncəlləmələr növbəti {pro} yenilənməsi zamanı qüvvəyə minəcək. - {app_pro} planınız aktivdir!\n\nPlanınız avtomatik olaraq {date} tarixində başqa bir {current_plan} üçün yenilənəcək. - {app_pro} planınızın müddəti {date} tarixində bitir.\n\nEksklüziv Pro özəlliklərinə kəsintisiz erişimi təmin etmək üçün planınızı indi güncəlləyin. - {pro} planı xətası - {app_pro} planınızın müddəti {date} tarixində bitir. - {pro} planı yüklənir - {pro} planınız barədə məlumatlar hələ də yüklənir. Bu proses tamamlanana qədər planınızı güncəlləyə bilməzsiniz. - {pro} planı yüklənir... - Hazırkı planınızı yükləmək üçün şəbəkəyə bağlana bilmir. Bağlantı bərpa olunana qədər {app_name} ilə planınızı güncəlləmək sıradan çıxarılacaq.\n\nLütfən şəbəkə bağlantınızı yoxlayıb yenidən sınayın. - {pro} planı tapılmadı - Hesabınız üçün heç bir aktiv plan tapılmadı. Bunun bir səhv olduğunu düşünürsünüzsə, lütfən kömək üçün {app_name} dəstəyi ilə əlaqə saxlayın. Başda {platform_store} Mağazası üzərindən {app_pro} üçün qeydiyyatdan keçdiyinizə görə, geri ödəmə tələbini göndərmək üçün eyni {platform_account} hesabını istifadə etməlisiniz. Başda {platform_store} Mağazası üzərindən {app_pro} üçün qeydiyyatdan keçdiyinizə görə, geri qaytarma tələbiniz {app_name} Dəstək komandası tərəfindən icra olunacaq.\n\nAşağıdakı düyməyə basaraq və geri ödəniş formunu dolduraraq geri ödəmə tələbinizi göndərin.\n\n{app_name} Dəstək komandası, geri ödəmə tələblərini adətən 24-72 saat ərzində emal edir, yüksək tələb həcminə görə bu proses daha uzun çəkə bilər. - {pro} planını geri qaytar - {pro} planını yenilə - Hazırda, {pro} planları, yalnızca {platform_store} və {platform_store} Mağazaları vasitəsilə satın alına və yenilənə bilər. {app_name} Masaüstü istifadə etdiyinizə görə planınızı burada yeniləyə bilməzsiniz.\n\n{app_pro} gəlişdiriciləri, istifadəçilərin {pro} planlarını {platform_store} və {platform_store} Mağazalarından kənarda almağına imkan verəcək alternativ ödəniş variantları üzərində ciddi şəkildə çalışırlar. {pro} Yol Xəritəsi {icon} - {platform_store} və ya {platform_store} Mağazaları vasitəsilə planınızı {app_name} quraşdırılmış və əlaqələndirilmiş cihazda {app_pro} ayarlarında yeniləyin. - {pro} üçün qeydiyyatdan keçdiyiniz {platform_account} hesabınızla {platform_store} veb saytında planınızı yeniləyin. - Güclü {app_pro} özəlliklərini yenidən istifadə etməyə başlamaq üçün {app_pro} planınızı yeniləyin. - {app_pro} planınız yeniləndi! {network_name} dəstək verdiyiniz üçün təşəkkürlər. - {pro} planı bərpa edildi - {app_pro} üçün yararlı bir plan aşkarlandı və {pro} statusunuz bərpa edildi! - Başda {platform_store} Mağazası üzərindən {app_pro} üçün qeydiyyatdan keçdiyinizə görə planınızı həmin {platform_account} vasitəsilə güncəlləməlisiniz. + {app_pro} erişiminiz yeniləndi! {network_name} dəstək verdiyiniz üçün təşəkkürlər. 1 ay - {monthly_price}/ay 3 ay - {monthly_price}/ay 12 ay - {monthly_price}/ay Getməyinizə məyus olduq. Geri ödəmə tələb etməzdən əvvəl bilməli olduğunuz şeylər. - {platform_account} hazırda geri ödəniş tələbinizi emal edir. Bu, adətən 24-48 saat çəkir. Onların qərarından asılı olaraq, {app_name} tətbiqində {pro} statusunuzun dəyişdiyini görə bilərsiniz. + {platform} hazırda geri ödəniş tələbinizi emal edir. Bu, adətən 24-48 saat çəkir. Onların qərarından asılı olaraq, {app_name} tətbiqində {pro} statusunuzun dəyişdiyini görə bilərsiniz. Geri qaytarma tələbiniz {app_name} Dəstək komandası tərəfindən icra olunacaq.\n\nAşağıdakı düyməyə basaraq və geri ödəniş formunu dolduraraq geri ödəniş tələbinizi göndərin.\n\n{app_name} Dəstək komandası, geri ödəniş tələblərini adətən 24-72 saat ərzində emal edir, yüksək tələb həcminə görə bu proses daha uzun çəkə bilər. - Geri ödəniş tələbiniz yalnız {platform_account} veb saytında {platform_account} hesabı üzərindən icra olunacaq.\n\n{platform_account} geri ödəniş siyasətlərinə əsasən, {app_name} gəlişdiriciləri, geri ödəniş tələblərinin nəticəsinə təsir edə bilməz. Bu, tələbin qəbul olunub-olunmaması ilə yanaşı, tam və ya qismən geri ödənişin verilib-verilməməsini də əhatə edir. - Geri ödəmə tələbinizlə bağlı daha çox güncəlləmə üçün lütfən {platform_account} ilə əlaqə saxlayın. {platform_account} geri ödəniş siyasətlərinə əsasən, {app_name} gəlişdiriciləri, geri ödəniş tələblərinin nəticəsinə təsir edə bilməz.\n\n{platform_store} Geri ödəmə dəstəyi + Geri ödəniş tələbiniz yalnız {platform} veb saytında {platform} hesabı üzərindən icra olunacaq.\n\n{platform} geri ödəniş siyasətlərinə əsasən, {app_name} gəlişdiriciləri, geri ödəniş tələblərinin nəticəsinə təsir edə bilməz. Bu, tələbin qəbul olunub-olunmaması ilə yanaşı, tam və ya qismən geri ödənişin verilib-verilməməsini də əhatə edir. + Geri ödəmə tələbinizlə bağlı daha çox güncəlləmə üçün lütfən {platform} ilə əlaqə saxlayın. {platform} geri ödəniş siyasətlərinə əsasən, {app_name} gəlişdiriciləri, geri ödəniş tələblərinin nəticəsinə təsir edə bilməz.\n\n{platform} Geri ödəmə dəstəyi {pro} geri ödəməsi - {app_pro} planları üçün geri ödəmələr yalnız {platform_store} Mağazası vasitəsilə {platform_account} tərəfindən həyata keçirilir.\n\n{platform_account} geri ödəniş siyasətlərinə əsasən, {app_name} gəlişdiriciləri, geri ödəniş tələblərinin nəticəsinə təsir edə bilməz. Bu, tələbin qəbul olunub-olunmaması ilə yanaşı, tam və ya qismən geri ödənişin verilib-verilməməsini də əhatə edir. + {app_pro} üçün geri ödəmələr yalnız {platform_store} vasitəsilə {platform} tərəfindən həyata keçirilir.\n\n{platform} geri ödəniş siyasətlərinə əsasən, {app_name} gəlişdiriciləri, geri ödəniş tələblərinin nəticəsinə təsir edə bilməz. Bu, tələbin qəbul olunub-olunmaması ilə yanaşı, tam və ya qismən geri ödənişin verilib-verilməməsini də əhatə edir. + {pro} Beta-nı yenilə + {pro} erişiminizi, {platform_store} və ya {platform_store_other} üzərindən {app_name} quraşdırılmış və əlaqələndirilmiş cihazın {app_pro} ayarlarında yeniləyin. + {app_name} tətbiqini yenidən maksimum potensialda istifadə etmək istəyirsiniz?\nQaçırdığınız özəlliklərin kilidini açmaq üçün {pro} erişiminizi yeniləyin. + Daha çox danışığı təkrar sancmaq istəyirsiniz?\nQaçırdığınız özəlliklərin kilidini açmaq üçün {pro} erişiminizi yeniləyin. + Pro yeniləməsi uğursuz oldu, tezliklə yenidən sınanacaq Geri ödəmə tələb edildi Daha çoxunu göndərmək üçün {pro} ayarları @@ -1004,14 +1068,11 @@ {pro} status yüklənir {pro} statusunuzu yoxlamaq üçün şəbəkəyə bağlana bilmir. Bağlantı bərpa olunana qədər {pro} ya yüksəldə bilməyəcəksiniz.\n\nLütfən şəbəkə bağlantınızı yoxlayıb yenidən sınayın. {pro} statusunuzu təzələmək üçün şəbəkəyə bağlana bilmir. Bu səhifədəki bəzi əməliyyatlar, bağlantı bərpa olunana qədər sıradan çıxarılacaq.\n\nLütfən şəbəkə bağlantınızı yoxlayıb yenidən sınayın. - {pro} planınızla bağlı kömək lazımdır? Dəstək komandamıza müraciət edin. + {pro} ilə bağlı kömək lazımdır? Dəstək komandasına sorğu göndər. Güncəlləyərək, {app_pro} Xidmət Şərtləri {icon} və Məxfilik Siyasəti {icon} ilə razılaşırsınız Limitsiz sancma Limitsiz sancılmış danışıqla bütün söhbətlərinizi təşkil edin. - Hazırda {current_plan} Planı üzərindəsiniz. {selected_plan} Planınana keçmək istədiyinizə əminsiniz?\n\nGüncəlləsəniz, planınız {date} tarixində əlavə {selected_plan} {pro} erişimi üçün avtomatik yenilənəcək. - Planınız {date} tarixində bitəcək.\n\nGüncəlləsəniz, planınız {date} tarixində əlavə {selected_plan} Pro erişimi üçün avtomatik olaraq yenilənəcək. - {app_name} tətbiqindən daha çox faydalanmaq istəyirsiniz? Daha güclü mesajlaşma təcrübəsi üçün {app_pro}-ya yüksəldin. - {platform_account} geri ödəmə tələbinizi emal edir + {platform} geri ödəmə tələbinizi emal edir Profil Ekran şəkli Ekran şəklini silmə uğursuz oldu. @@ -1019,6 +1080,11 @@ Lütfən daha kiçik bir fayl götürün. Profili güncəlləmək uğursuz oldu. Yüksəlt + Adminlər, son 14 günə aid mesaj tarixçəsini görə biləcək. Onların vəzifəsini azaltmaq və ya onları qrupdan xaric etmək mümkün deyil. + + Üzvü yüksəlt + Üzvləri yüksəlt + Yüksəltmə uğursuz oldu Yüksəltmələr uğursuz oldu @@ -1067,19 +1133,51 @@ Bu, sizin geri qaytarma parolunuzdur. Kiməsə göndərsəniz, hesabınıza tam erişə bilər. Qrupu yenidən yarat Təkrar et - Başda fərqli {platform_account} vasitəsilə {app_pro} üçün qeydiyyatdan keçdiyinizə görə planınızı həmin {platform_account} vasitəsilə güncəlləməlisiniz. + Başda fərqli {platform_account} vasitəsilə {app_pro} üçün qeydiyyatdan keçdiyinizə görə planınızı +{pro} erişiminizi güncəlləmək üçün həmin {platform_account} platformasını istifadə etməlisiniz. + Geri qaytarma tələb etmək üçün iki üsul: Mesajın uzunluğunu {count} qədər azalt %1$d xarakter qaldı %1$d xarakter qaldı + Daha sonra xatırlat Sil + + Üzvü xaric et + Üzvləri xaric et + + + Üzvü xaric et və mesajlarını sil + Üzvləri xaric et və mesajlarını sil + Parol silmə uğursuz oldu {app_name} üçün hazırkı parolunuzu silin. Daxili olaraq saxlanılmış verilər, cihazınızda saxlanılan təsadüfi yaradılmış açarla təkrar şifrələnəcək. + + Üzv çıxarılır + Üzvlər çıxarılır + Yenilə + {pro} yeniləmə Cavabla Geri ödəmə tələb et Təkrar göndər + + Dəvəti təkrar göndər + Dəvətləri təkrar göndər + + + Yüksəltməni təkrar göndər + Yüksəltmələri təkrar göndər + + + Dəvətlər təkrar göndərilir + Dəvət təkrar göndərilir + + + Yüksəltmə təkrar göndərilir + Yüksəltmələr təkrar göndərilir + Ölkə məlumatları yüklənir... Yenidən başlat Təkrar sinxronlaşdır @@ -1180,6 +1278,9 @@ Geri al Bilinmir Dəstəklənməyən CPU + Güncəllə + {pro} erişimini güncəllə + {pro} erişiminizi güncəlləməyin iki yolu var: Tətbiq güncəlləmələri İcma məlumatlarını güncəllə İcma adı və açıqlaması, bütün icma üzvlərinə görünür @@ -1194,8 +1295,6 @@ Zəhmət olmasa, qrupun daha qısa təsvirini daxil edin {app_name} üçün yeni versiyası mövcuddur, güncəlləmək üçün toxunun Yeni {app_name} versiyası ({version}) mövcuddur. - Planı güncəllə - Planınızı güncəlləməyin iki yolu var: Profil məlumatlarını güncəllə Ekran adınız və ekran şəkliniz bütün danışıqlarda görünür. Buraxılış qeydlərinə get @@ -1204,6 +1303,8 @@ Son güncəlləmə: {relative_time} əvvəl Güncəlləmələr Güncəllənir... + Yüksəlt + {app_name} - yüksəlt Yüksəlt Yüklənir URL-ni kopyala @@ -1212,8 +1313,8 @@ Bu URL-ni brauzerinizdə açmaq istədiyinizə əminsiniz?\n\n{url} Keçidlər, brauzerinizdə açılacaq. Sürətli rejimi istifadə et - {platform_store} veb saytı vasitəsilə - Qeydiyyatdan keçərkən istifadə etdiyiniz {platform_account} hesabı ilə {platform_store} veb saytı üzərindən planınızı dəyişdirin. + {platform} veb saytı vasitəsilə + Qeydiyyatdan keçdiyiniz {platform_account} hesabını istifadə edərək {pro} erişiminizi {platform_store} veb saytı üzərindən güncəlləyin. Video Video oxudula bilmir. Göstər diff --git a/app/src/main/res/values-b+ca+ES/strings.xml b/app/src/main/res/values-b+ca+ES/strings.xml index f67d819d9e..8f87d6b9c2 100644 --- a/app/src/main/res/values-b+ca+ES/strings.xml +++ b/app/src/main/res/values-b+ca+ES/strings.xml @@ -15,7 +15,6 @@ Aquest és el vostre ID de compte. Els altres usuaris el poden escanejar per a encetar una conversa amb vós. Mida actual Afegir - Afegiu administradors Introdueix l\'ID del compte de l\'usuari que estàs promocionant a administrador.\n\nPer afegir diversos usuaris, introdueix cada ID del compte separat per una coma. Es poden especificar fins a 20 identificadors de compte alhora. Els administradors no es poden eliminar. {name} i {count} altres han estat ascendits a Admin. @@ -820,13 +819,9 @@ Vista prèvia Ja ho tens Endavant i penja GIFs i imatges del webp animat per a la teva imatge de visualització! - Obteniu imatges de visualització animada i desbloquegeu funcions premium amb {app_pro} Imatge de pantalla animada els usuaris poden penjar GIFs Penja els gifs amb - Voleu enviar missatges més llargs? Envia més text i desbloqueja funcions premium amb {app_pro} - Vols més pins? Organitzes els teus xats i desbloqueges les funcions premium amb {app_pro} - Vols més de 5 pins? Organitzes els teus xats i desbloqueges les funcions premium amb {app_pro} Xateja de grups més grans fins a 300 membres Plus carrega funcions més exclusives Missatges de fins a 10,000 caràcters @@ -837,7 +832,6 @@ Augment de la longitud del missatge Aquest missatge va utilitzar les funcions següents {app_pro}: Envia més amb - Vols treure més informació de {app_name}? Actualitzes a {app_pro} per a una experiència de missatgeria més potent. Perfil Imatge de perfil Error en eliminar la imatge de perfil. diff --git a/app/src/main/res/values-b+cs+CZ/strings.xml b/app/src/main/res/values-b+cs+CZ/strings.xml index 7e886a43f3..dee81f75ac 100644 --- a/app/src/main/res/values-b+cs+CZ/strings.xml +++ b/app/src/main/res/values-b+cs+CZ/strings.xml @@ -15,8 +15,14 @@ Toto je vaše ID účtu. Ostatní uživatelé jej mohou naskenovat, aby s vámi mohli zahájit konverzaci. Skutečná velikost Přidat - Přidat správce + + Přidat správce + Přidat správce + Přidat správce + Přidat správce + Zadejte ID účtu uživatele, kterého povyšujete na správce.\n\nChcete-li přidat více uživatelů, zadejte každé ID účtu oddělené čárkou. Najednou lze zadat až 20 ID účtů. + Správci nemohou být poníženi ani odebráni ze skupiny. Správce nelze odebrat. {name} a {count} dalších byli povýšeni na správce. Povýšit na správce @@ -41,6 +47,12 @@ {name} byl/a odebrán/a jako správce. {name} a {count} dalším bylo odebráno správcovství. {name} a {other_name} bylo odebráno správcovství. + + %1$d správce vybrán + %1$d správcové vybráni + %1$d správců vybráno + %1$d správců vybráno + Odesílání povýšení na správce Odesílání povýšení na správce @@ -48,7 +60,9 @@ Odesílání povýšení na správce Nastavení správce + Nemůžete změnit svůj stav správce. Chcete-li skupinu opustit, otevřete nastavení konverzace a vyberte možnost Opustit skupinu. {name} a {other_name} byli povýšeni na správce. + Správci +{count} Anonymní Ikona aplikace @@ -193,6 +207,10 @@ Povolí hlasové a video hovory k ostatním uživatelům i od nich. Volali jste {name} Zmeškali jste hovor od {name}, protože jste neměli povoleny Hlasové a video hovory v nastavení ochrany soukromí. + Aplikace {app_name} vyžaduje přístup k vašemu fotoaparátu, aby mohla provádět videohovory, ale toto oprávnění bylo zamítnuto. Během hovoru nelze aktualizovat oprávnění k fotoaparátu.\n\nChcete nyní ukončit hovor a povolit přístup k fotoaparátu, nebo chcete, aby vám to bylo připomenuto po skončení hovoru? + Chcete-li povolit přístup k fotoaparátu, otevřete nastavení a povolte oprávnění k fotoaparátu. + Během posledního hovoru jste se pokusili použít video, ale nebylo to možné, protože přístup k fotoaparátu byl dříve zamítnut. Chcete-li povolit přístup k fotoaparátu, otevřete nastavení a povolte oprávnění k fotoaparátu. + Je vyžadován přístup k fotoaparátu Nebyla nalezena žádná kamera Kamera není dostupná. Povolit přístup k fotoaparátu @@ -200,12 +218,18 @@ {app_name} potřebuje přístup k fotoaparátu pro pořizování fotografií a videí nebo skenování QR kódů. {app_name} potřebuje přístup k fotoaparátu ke skenování QR kódů Zrušit - Zrušit tarif + Zrušit {pro} + Zrušit tarif {pro} + Zrušení proveďte na webu {platform} pomocí {platform_account}, kterým jste si zaregistrovali {pro}. + Zrušení proveďte na webu {platform_store} pomocí {platform_account}, kterým jste si zaregistrovali {pro}. Změnit Změna hesla selhala Změňte své heslo pro {app_name}. Lokálně uložená data budou znovu zašifrována pomocí vašeho nového hesla. Kontrola stavu {pro} - Kontrolují se vaše údaje {pro}. Některé informace na této stránce mohou být nepřesné, dokud kontrola nebude dokončena. + Kontroluje se váš stav {pro}. Jakmile kontrola skončí, budete moci pokračovat. + Kontrolují se vaše údaje {pro}. Některé akce na této stránce nemusí být dostupné, dokud kontrola nebude dokončena. + Kontrola stavu {pro}... + Kontrolují se podrobnosti vašeho {pro}. Dokud tato kontrola není dokončena, není možné prodloužení. Kontroluje se váš stav {pro}. Jakmile kontrola skončí, budete moci navýšit na {pro}. Smazat Smazat vše @@ -267,11 +291,19 @@ Adresa komunity Kopírovat adresu komunity Potvrdit + Potvrdit povýšení + Jste si jistí? Správci nemohou být ponížení ani odebráni ze skupiny. Kontakty Smazat kontakt Jste si jisti, že chcete smazat {name} ze svých kontaktů? Nové zprávy od {name} budou doručeny jako žádosti o komunikaci. Zatím nemáte žádné kontakty Vybrat kontakty + + %1$d kontakt vybráno + %1$d kontakty vybrány + %1$d kontaktů vybráno + %1$d kontaktů vybráno + Podrobnosti uživatele Kamera Zvolte akci pro zahájení konverzace @@ -312,8 +344,8 @@ Kopírovat Vytvořit Vytváření hovoru + Současné fakturování Aktuální heslo - Současný tarif Vyjmout Tmavý režim Opravdu chcete smazat všechny zprávy, přílohy a data účtu z tohoto zařízení a vytvořit nový účet? @@ -340,6 +372,18 @@ Počkejte prosím, než se skupina vytvoří... Aktualizace skupiny selhala Nemáte oprávnění k mazání zpráv ostatních + + Smazat vybranou přílohu + Smazat vybrané přílohy + Smazat vybrané přílohy + Smazat vybrané přílohy + + + Opravdu chcete smazat vybranou přílohu? Zpráva přidružená k této příloze bude také smazána. + Opravdu chcete smazat vybrané přílohy? Zpráva přidružená k těmto přílohám bude také smazána. + Opravdu chcete smazat vybrané přílohy? Zpráva přidružená k těmto přílohám bude také smazána. + Opravdu chcete smazat vybrané přílohy? Zpráva přidružená k těmto přílohám bude také smazána. + Opravdu chcete smazat {name} z vašich kontaktů?\n\nTím smažete vaše konverzace, včetně všech zpráv a příloh. Budoucí zprávy od {name} se zobrazí jako žádost o komunikaci. Opracdu chcete smazat konverzaci s {name}?\nTím trvale smažete všechny zprávy a přílohy. @@ -471,19 +515,29 @@ Vy a {name} reagovali {emoji_name} Reagoval(a) na vaši zprávu {emoji} Povolit + Povolit přístup k fotoaparátu? Zobrazit upozornění při přijetí nových zpráv. + Pro povolení ukončit hovor Líbí se vám {app_name}? Potřebuje vylepšit {emoji} Skvělé {emoji} Používáte {app_name} \na rádi bychom znali váš názor. Vstoupit - Chyba kontroly stavu {pro}. + Zadejte heslo, které jste nastavili pro {app_name} + Zadejte heslo, které používáte k odemknutí {app_name} při spuštění, nikoli heslo pro obnovení + Chyba kontroly stavu {pro} Zkontrolujte prosím připojení k internetu a zkuste to znovu. Zkopírovat chybu a ukončit Chyba databáze Něco se pokazilo. Zkuste to prosím později. - Chyba načítání tarifu {pro}. + Chyba načítání přístupu k {pro} + {app_name} nemohla vyhledat tento ONS. Zkontrolujte prosím připojení k internetu a zkuste to znovu. Došlo k neznámé chybě. + Tento ONS není registrován. Zkontrolujte prosím jeho správnost a zkuste to znovu. + Nepodařilo se znovu odeslat pozvánku pro {name} ve skupině {group_name} + Nepodařilo se znovu odeslat pozvánku pro {name} a {count} dalších ve skupině {group_name} + Nepodařilo se znovu odeslat pozvánku pro {name} a {other_name} ve skupině {group_name} + Nepodařilo se znovu odeslat pozvánku pro {name} a {other_name} ve skupině {group_name} Stahování selhalo Chyby Zpětná vazba @@ -539,6 +593,9 @@ Opravdu chcete opustit {group_name}? Jste si jisti, že chcete opustit {group_name}?\n\nTímto odeberete všechny členy a smažete veškerý obsah skupiny. Selhalo odhlášení ze skupiny {group_name} + {name} byl(a) pozván(a) do skupiny. Byla sdílena i historie konverzací skupiny za posledních 14 dní. + {name} a {count} další byli pozváni do skupiny. Byla sdílena i historie konverzací skupiny za posledních 14 dní. + {name} a {other_name} byli pozváni do skupiny. Byla sdílena i historie konverzací skupiny za posledních 14 dní. {name} opustil(a) skupinu. {name} a {count} dalších opustilo skupinu. {name} a {other_name} opustili skupinu. @@ -550,6 +607,9 @@ {name} a {other_name} byli pozváni do skupiny. Vy a {count} dalších bylo pozváno do skupiny. Historie konverzace byla sdílena. Vy a {other_name} jste byli pozvání do skupiny. Historie konverzace byla sdílena. + Nepodařilo se odebrat {name} z {group_name} + Nepodařilo se odebrat {name} a {count} dalších z {group_name} + Nepodařilo se odebrat {name} a {other_name} z {group_name} Opustil/a jste skupinu. Členové skupiny V této skupině nejsou žádní další členové. @@ -563,6 +623,7 @@ Nemáte žádné zprávy od {group_name}. Pošlete zprávu pro zahájení konverzace! Tato skupina nebyla aktualizována déle než 30 dní. Může dojít k problémům s odesíláním zpráv nebo zobrazováním informací o skupině. Jste jediný správce ve skupině {group_name}.\n\nČlenové skupiny a nastavení nelze změnit bez správce. + Jste jediný správce skupiny {group_name}.\n\nČlenové skupiny a nastavení nelze změnit bez správce. Pokud chcete skupinu opustit bez jejího smazání, nejprve přidejte nového správce Čeká na odebrání Byli jste povýšeni na správce. Vy a {count} dalších jste byli povýšeni na správce. @@ -571,16 +632,16 @@ Chcete odstranit {name} a {count} dalších ze skupiny {group_name}? Chcete odstranit {name} a {other_name} ze skupiny {group_name}? - Odstranit uživatele a jeho zprávy - Odstranit uživatele a jejich zprávy - Odstranit uživatele a jejich zprávy - Odstranit uživatele a jejich zprávy + Odebrat uživatele a jeho zprávy + Odebrat uživatele a jejich zprávy + Odebrat uživatele a jejich zprávy + Odebrat uživatele a jejich zprávy - Odstranit uživatele - Odstranit uživatele - Odstranit uživatele - Odstranit uživatele + Odebrat uživatele + Odebrat uživatele + Odebrat uživatele + Odebrat uživatele {name} byl odebrán ze skupiny. {name} a {count} dalších byli odebráni ze skupiny. @@ -616,6 +677,12 @@ Požadovat anonymní režim, pokud je k dispozici. V závislosti na klávesnici, kterou používáte, může být tento požadavek ignorován. Info Chybná zkratka + + Pozvat kontakt + Pozvat kontakty + Pozvat kontakty + Pozvat kontakty + Pozvání selhalo Pozvání selhala @@ -628,6 +695,13 @@ Pozvánky nemohly být odeslány. Chcete to zkusit znovu? Pozvánky nemohly být odeslány. Chcete to zkusit znovu? + + Pozvat člena + Pozvat členy + Pozvat členy + Pozvat členy + + Pozvěte nového člena do skupiny zadáním ID účtu vašeho přítele, ONS nebo naskenováním jejich QR kódu {icon} Připojit Později Spustit {app_name} automaticky při spuštění počítače. @@ -671,10 +745,17 @@ Klepněte pro odemčení {app_name} je odemčena Logy + Spravovat správce Spravovat členy Spravovat {pro} Maximum Média + + %1$d člen vybrán + %1$d členové vybráni + %1$d členů vybráno + %1$d členů vybráno + %1$d člen %1$d členové @@ -689,6 +770,7 @@ Přidat ID účtu nebo ONS Pozvat kontakty + Nemáte žádné kontakty, které byste mohli pozvat do této skupiny.\nVraťte se zpět později a pozvěte členy pomocí jejich ID účtu nebo ONS. Odeslat pozvánku Odeslat pozvánky @@ -701,6 +783,7 @@ Sdílet historii zpráv Sdílet pouze nové zprávy Pozvat + Členové (ne správci) Panel menu Zpráva Více @@ -721,6 +804,7 @@ Zahajte novou konverzaci zadáním ID účtu vašeho přítele nebo ONS. Zahajte novou konverzaci zadáním ID účtu vašeho přítele, ONS nebo naskenováním jejich QR kódu. + Zahajte novou konverzaci zadáním ID účtu vašeho přítele, ONS nebo naskenováním jejich QR kódu {icon} Máte novou zprávu. Máte %1$d nové zprávy. @@ -784,7 +868,10 @@ Odstranit přezdívku Nastavit přezdívku Ne + V této skupině nejsou žádní členové, kromě správců. Žádné návrhy + Ve všech konverzacích můžete odesílat zprávy o délce až 10000 znaků. + Organizujte komunikace pomocí neomezeného počtu připnutých konverzací. Nic Teď ne Poznámka sobě @@ -835,7 +922,11 @@ OK Zap. Na vašem zařízení {device_type} - Otevřete tento účet {app_name} na zařízení {device_type}, které je přihlášeno do účtu {platform_account}, pomocí kterého jste se původně zaregistrovali. Poté změňte svůj tarif v nastavení {app_pro}. + Otevřete tento účet {app_name} na zařízení {device_type}, které je přihlášeno do účtu {platform_account}, pomocí kterého jste se původně zaregistrovali. Poté zrušte {pro} v nastavení {app_pro}. + Otevřete tento účet {app_name} na zařízení {device_type}, které je přihlášeno do {platform_account}, pomocí kterého jste se původně zaregistrovali. Poté aktualizujte svůj přístup {pro} v nastavení {app_pro}. + Na propojeném zařízení + Na webových stránkách {platform_store} + Na webových stránkách {platform} Vytvořit účet Účet vytvořen Mám účet @@ -860,7 +951,9 @@ Nepodařilo se rozpoznat tento ONS. Zkontrolujte ho a zkuste to znovu. Nepodařilo se vyhledat tento ONS. Zkuste to prosím později. Otevřít - Otevřít webovou stránku {platform_store} + Otevřete webovou stránku {platform_store} + Otevřete webovou stránku {platform} + Otevřít nastavení Otevřít dotazník Ostatní Heslo @@ -937,18 +1030,38 @@ Předvolby Náhled Náhled upozornění + Váš přístup k {pro} je aktivní!\n\nPřístup k {pro} bude automaticky prodloužen na další {current_plan_length} dne {date}. + Váš přístup k {pro} vyprší dne {date}.\n\nAktualizujte si přístup k {pro} nyní, abyste zajistili automatické prodloužení před vypršením {pro}. + Váš přístup k {pro} je aktivní!\n\nVáš přístup k {pro} bude automaticky prodloužen na další\n{current_plan_length} dne {date}. Veškeré změny, které zde provedete, se projeví při dalším prodloužení. + Chyba přístupu k {pro} + Váš přístup k {pro} vyprší dne {date}. + Načítání přístupu k {pro} + Informace o vašem přístupu k {pro} se stále načítají. Dokud nebude tento proces dokončen, nemůžete provézt aktualizaci. + Načítání přístupu k {pro}... + Nelze se připojit k síti aby byly načteny informace o vašem přístupu k {pro}. Aktualizace {pro} prostřednictvím {app_name} nebude aktivní, dokud nebude obnoveno připojení.\n\nZkontrolujte připojení k síti a zkuste to znovu. + Přístup k {pro} nebyl nalezen + Aplikace {app_name} zjistila, že váš účet nemá přístup k {pro}. Pokud si myslíte, že se jedná o chybu, kontaktujte podporu {app_name} a požádejte o pomoc. + Obnovit přístup k {pro} + Prodloužit přístup k {pro} + V současnosti lze přístup k {pro} zakoupit a prodloužit pouze prostřednictvím {platform_store} nebo {platform_store_other}. Protože používáte {app_name} Desktop, nemůžete zde provést prodloužení.\n\nVývojáři {app_name} intenzivně pracují na alternativních platebních možnostech, které by uživatelům umožnily zakoupit přístup k {pro} mimo {platform_store} a {platform_store_other}. Plán vývoje {pro} {icon} + Prodlužte si přístup k {pro} na webu {platform_store} pomocí {platform_account}, kterým jste si zaregistrovali {pro}. + Prodlužte na webu {platform} pomocí {platform_account}, kterým jste si zaregistrovali {pro}. + Prodlužte svůj přístup k {pro}, abyste mohli znovu používat výkonné funkce {app_pro} Beta. + Přístup k {pro} obnoven + Aplikace {app_name} rozpoznala a obnovila vašemu účtu přístup k {pro}. Váš stav {pro} byl obnoven! + Protože jste si původně zaregistrovali {app_pro} přes {platform_store}, je třeba, abyste k aktualizaci svého přístupu k {pro} využili svůj {platform_account}. + V současnosti lze přístup k {pro} zakoupit pouze prostřednictvím {platform_store} nebo {platform_store_other}. Protože používáte {app_name} Desktop, nemůžete zde provést navýšení na {pro}.\n\nVývojáři {app_name} intenzivně pracují na alternativních platebních možnostech, které by uživatelům umožnily zakoupit přístup k {pro} mimo {platform_store} a {platform_store_other}. Plán vývoje {pro} {icon} Aktivováno Vše je nastaveno! - Váš tarif {app_pro} byl aktualizován! Účtování proběhne při automatickém obnovení vašeho aktuálního tarifu {pro} dne {date}. + Váš přístup k {app_pro} byl aktualizován! Účtování proběhne při automatickém prodloužení {pro} dne {date}. Už máte Jako váš zobrazovaný profilový obrázek nastavte animovaný GIF nebo WebP! - Získejte možnost nahrát animovaný zobrazovaný obrázek profilu a další prémiové funkce se Session Pro Animovaný zobrazovaný obrázek uživatelé mohou nahrávat GIFy Animované zobrazované obrázky Nastavte si animované obrázky GIF a WebP jako svůj zobrazovaný profilový obrázek. Nahrajte GIFy se - {pro} se automaticky obnoví za {time} + {pro} bude automaticky prodlouženo za {time} Odznak {pro} Zobrazit odznak {app_pro} ostatním uživatelům Odznaky @@ -963,21 +1076,19 @@ {price} účtováno ročně {price} účtováno měsíčně {price} účtováno čtvrtletně - Chcete posílat delší zprávy? Posílejte více textu odemknutím prémiových funkcí Session Pro - Chcete více připnutí? Organizujte své chaty a odemkněte prémiové funkce pomocí Session Pro - Chcete více než 5 připnutí? Organizujte své chaty a odemkněte prémiové funkce pomocí Session Pro + Mrzí nás, že rušíte {pro}. Než zrušíte {pro}, přečtěte si, co byste měli vědět. Zrušit - Zrušení vašeho tarifu {app_pro} zabrání jeho automatickému obnovení před vypršením {pro}. Zrušením {pro} nedojde k vrácení peněz. Funkce {app_pro} budete moci využívat až do vypršení platnosti vašeho tarifu.\n\n -Protože jste si původně tarif {app_pro} aktivovali přes {platform_account}, bude ke zrušení tarifu potřeba použít stejný účet {platform_account}. - Dva způsoby, jak zrušit váš tarif: - Opravdu chcete smazat svá data z tohoto zařízení?\n\n{app_pro} nelze převést na jiný účet. Uložte si své heslo pro obnovení, abyste mohli svůj tarif {pro} později obnovit. - Opravdu chcete smazat svá data ze sítě? Pokud budete pokračovat, nebudete moci obnovit vaše zprávy ani kontakty.\n\n{app_pro} nelze převést na jiný účet. Uložte si své heslo pro obnovení, abyste mohli svůj tarif {pro} později obnovit. - Váš aktuální tarif je již zlevněn o {percent} % z plné ceny {app_pro}. + Zrušení přístupu k {pro} zabrání jeho automatickému prodloužení před vypršením platnosti přístupu k {pro}. Zrušením {pro} nedojde k vrácení peněz. Funkce {app_pro} budete moci využívat až do vypršení přístupu k {pro}.\n\nProtože jste si původně {app_pro} zařídili pomocí účtu {platform_account}, bude ke zrušení přístupu k {pro} potřeba použít stejný {platform_account}. + Dva způsoby, jak zrušit váš přístup k {pro}: + Zrušením přístupu k {pro} zabráníte jeho automatickému prodloužení předtím, než {pro} vyprší.\n\nZrušení {pro} neznamená vrácení peněz. Funkce {app_pro} můžete používat až do vypršení přístupu {pro}. + Vyberte si možnost přístupu k {pro}, která vám vyhovuje nejlépe.\nDelší přístup znamená vyšší slevu. + Opravdu chcete smazat svá data z tohoto zařízení?\n\n{app_pro} nelze převést na jiný účet. Uložte si své heslo pro obnovení, abyste mohli svůj přístup k {pro} později obnovit. + Opravdu chcete smazat svá data ze sítě? Pokud budete pokračovat, nebudete moci obnovit vaše zprávy ani kontakty.\n\n{app_pro} nelze převést na jiný účet. Uložte si své heslo pro obnovení, abyste mohli svůj přístup k {pro} později obnovit. + Váš přístup k {pro} je již zlevněn o {percent} % z plné ceny {app_pro}. Chyba obnovování stavu {pro} Platnost vypršela - Bohužel, váš tarif {pro} vypršel. Obnovte jej, abyste nadále měli přístup k exkluzivním výhodám a funkcím {app_pro}. + Bohužel, váš tarif {pro} vypršel. Prodlužte jej, abyste znovu získali přístup k exkluzivním výhodám a funkcím {app_pro}. Brzy vyprší - Váš tarif {pro} vyprší za {time}. Aktualizujte si tarif, abyste i nadále měli přístup k exkluzivním výhodám a funkcím {app_pro}. {pro} vyprší za {time} {pro} FAQ Najděte odpovědi na časté dotazy v nápovědě {app_pro}. @@ -986,6 +1097,7 @@ Protože jste si původně tarif {app_pro} aktivovali přes {platform_account A další exkluzivních funkce Zprávy až do 10000 znaků Připněte neomezený počet konverzací + Chcete využít potenciál {app_name} naplno?\nPřejděte na {app_pro} Beta a získejte přístup k mnoha exkluzivním výhodám a funkcím. Skupina aktivována Tato skupina má navýšenou kapacitu! Může podporovat až 300 členů, protože správce skupiny má @@ -994,7 +1106,7 @@ Protože jste si původně tarif {app_pro} aktivovali přes {platform_account %1$s skupin navýšeno %1$s skupin navýšeno - Žádost o vrácení peněz je konečné. Pokud bude schváleno, váš tarif {pro} bude ihned zrušen a ztratíte přístup ke všem funkcím {pro}. + Žádost o vrácení peněz je konečná. Pokud bude schválena, váš přístup k {pro} bude ihned zrušen a ztratíte přístup ke všem funkcím {pro}. Navýšená velikost přílohy Navýšená délka zprávy Větší skupiny @@ -1009,7 +1121,11 @@ Protože jste si původně tarif {app_pro} aktivovali přes {platform_account %1$s delších zpráv odesláno V této zprávě byly použity následující funkce {app_pro}: - Nyní jsou k dispozici tři způsoby obnovy: + Pomocí nové instalace + Znovu nainstalujte {app_name} na tomto zařízení prostřednictvím {platform_store}, obnovte svůj účet pomocí hesla pro obnovení a prodlužte {pro} v nastavení {app_pro}. + Znovu nainstalujte {app_name} na tomto zařízení prostřednictvím {platform_store}, obnovte svůj účet pomocí hesla pro obnovení a navyšte na {pro} v nastavení {app_pro}. + Nyní jsou k dispozici tři způsoby prodloužení: + Nyní jsou k dispozici dva způsoby prodloužení: Sleva {percent} % %1$s připnutá konverzace @@ -1017,44 +1133,33 @@ Protože jste si původně tarif {app_pro} aktivovali přes {platform_account %1$s připnutých konverzací %1$s připnutých konverzací - Váš tarif {app_pro} je aktivní!\n\nVáš tarif se automaticky obnoví na další {current_plan} dne {date}. Změny tarifu se projeví při příštím obnovení {pro}. - Váš tarif {app_pro} je aktivní!\n\nTarif se automaticky obnoví na další {current_plan} dne {date}. - Váš tarif {app_pro} vyprší dne {date}.\n\nAktualizujte si tarif nyní, abyste měli i nadále nepřerušený přístup k exkluzivním funkcím Pro. - Chyba tarifu {pro} - Váš tarif {app_pro} vyprší dne {date}. - Načítání tarifu {pro} - Váš tarif {pro} se stále načítá. Dokud nebude načítání dokončeno, nemůžete tarif změnit. - Načítání tarifu {pro}... - Nelze se připojit k síti a načíst váš aktuální tarif. Aktualizace tarifu prostřednictvím {app_name} bude deaktivována, dokud nebude obnoveno připojení.\n\nZkontrolujte připojení k síti a zkuste to znovu. - {pro} nebyl nalezen - Pro váš účet nebyl nalezen žádný aktivní tarif. Pokud si myslíte, že se jedná o chybu, kontaktujte podporu {app_name} a požádejte o pomoc. Protože jste se původně zaregistrovali do {app_pro} přes obchod {platform_store}, budete muset pro žádost o vrácení peněz použít stejný účet {platform_account}. - Protože jste si původně zakoupili {app_pro} přes obchod {platform_store}, váš požadavek na vrácení peněz bude zpracován podporou {app_name}.\n\nPožádejte o vrácení peněz kliknutím na tlačítko níže a vyplněním formuláře žádosti o vrácení peněz.\n\nAčkoliv se podpora {app_name} snaží zpracovat žádosti o vrácení peněz během 24–72 hodin, může zpracování trvat i déle, pokud dochází k vysokému počtu žádostí. - Znovu nabýt tarif {pro} - Obnovit tarif {pro} - V současnosti lze tarify {pro} zakoupit a obnovit pouze prostřednictvím obchodů {platform_store} nebo {platform_store}. Protože používáte {app_name} Desktop, nemůžete zde svůj plán obnovit.\n\nVývojáři {app_pro} intenzivně pracují na alternativních platebních možnostech, které by uživatelům umožnily zakoupit tarify {pro} mimo obchody {platform_store} a {platform_store}. Plán vývoje {pro} {icon} - Obnovte svůj tarif v nastavení {app_pro} na propojeném zařízení s nainstalovanou aplikací {app_name} prostřednictvím obchodu {platform_store} nebo {platform_store}. - Obnovte svůj tarif na webu {platform_store} pomocí účtu {platform_account}, se kterým jste si pořídili {pro}. - Obnovte váš tarif {app_pro}, abyste mohli znovu využívat užitečné funkce {app_pro}. - Váš tarif {app_pro} byl obnoven! Děkujeme, že podporujete síť {network_name}. - {pro} obnoven - Byl rozpoznán platný tarif {app_pro} a váš stav {pro} byl obnoven! - Protože jste se původně zaregistrovali do {app_pro} přes {platform_store}, je třeba abyste pro aktualizaci vašeho tarifu použili svůj {platform_account}. + Protože jste si původně zakoupili {app_pro} přes obchod {platform_store}, váš požadavek na vrácení peněz bude zpracován podporou {app_name}.\n\nPožádejte o vrácení peněz kliknutím na tlačítko níže a vyplněním formuláře žádosti o vrácení peněz.\n\nAčkoliv se podpora {app_name} snaží zpracovat žádosti o vrácení peněz během 24-72 hodin, může zpracování trvat i déle, pokud dochází k vysokému počtu žádostí. + Váš přístup k {app_pro} byl prodloužen! Děkujeme, že podporujete síť {network_name}. 1 měsíc – {monthly_price} / měsíc 3 měsíce – {monthly_price} / měsíc 12 měsíců – {monthly_price} / měsíc + Otevřete tento účet {app_name} na zařízení {device_type}, které je přihlášeno k účtu {platform_account}, se kterým jste se původně zaregistrovali. Poté požádejte o vrácení platby přes nastavení {app_pro}. Mrzí nás, že to rušíte. Než požádáte o vrácení peněz, přečtěte si informace, které byste měli vědět. - {platform_account} nyní zpracovává vaši žádost o vrácení peněz. Obvykle to trvá 24–48 hodin. V závislosti na jejich rozhodnutí se může váš stav {pro} v aplikaci {app_name} změnit. + {platform} nyní zpracovává vaši žádost o vrácení peněz. Obvykle to trvá 24-48 hodin. V závislosti na jejich rozhodnutí se může váš stav {pro} v aplikaci {app_name} změnit. Váš požadavek na vrácení peněz bude zpracován podporou {app_name}.\n\nPožádejte o vrácení peněz kliknutím na tlačítko níže a vyplněním formuláře žádosti o vrácení peněz.\n\nAčkoliv se podpora {app_name} snaží zpracovat žádosti o vrácení peněz během 24–72 hodin, může zpracování trvat i déle, v případě že je vyřizováno mnoho žádostí. - Vaši žádost o vrácení peněz bude vyřizovat výhradně {platform_account} prostřednictvím webových stránek {platform_account}.\n\nVzhledem k pravidlům vracení peněz {platform_account} nemají vývojáři {app_name} žádný vliv na výsledek žádostí o vrácení peněz. To zahrnuje i rozhodnutí, zda bude žádost schválena nebo zamítnuta, a také, zda bude vrácena část peněz, nebo všechny peníze. - Pro další informace o vaší žádosti o vrácení peněz kontaktujte prosím {platform_account}. Vzhledem k zásadám pro vrácení peněz {platform_account} nemají vývojáři aplikace {app_name} žádnou možnost ovlivnit výsledek žádosti o vrácení.\n\nPodpora vrácení peněz {platform_store} + Vaši žádost o vrácení peněz bude vyřizovat výhradně {platform} prostřednictvím webu {platform}.\n\nVzhledem k pravidlům vracení peněz {platform} nemají vývojáři {app_name} žádný vliv na výsledek žádostí o vrácení peněz. To zahrnuje i rozhodnutí, zda bude žádost schválena nebo zamítnuta, a také, zda bude vrácena část peněz, nebo všechny peníze. + Pro další informace o vaší žádosti o vrácení peněz kontaktujte prosím {platform}. Vzhledem k zásadám pro vrácení peněz {platform} nemají vývojáři aplikace {app_name} žádnou možnost ovlivnit výsledek žádosti o vrácení.\n\nPodpora vrácení peněz {platform} Vracení peněz za {pro} - Vrácení peněz za tarify {app_pro} je vyřizováno výhradně prostřednictvím {platform_account} v obchodě {platform_store}.\n\nVzhledem k pravidlům vracení peněz služby {platform_account} nemají vývojáři {app_name} žádný vliv na výsledek žádostí o vrácení peněz. To zahrnuje i rozhodnutí, zda bude žádost schválena nebo zamítnuta, a také, zda bude vrácena část peněz, nebo všechny peníze. - Obnovit {pro} Beta - V současnosti lze tarify {pro} zakoupit a obnovit pouze prostřednictvím obchodů {platform_store} nebo {platform_store}. Protože jste nainstalovali {app_name} pomocí {buildVariant}, nemůžete zde svůj tarif obnovit.\n\nVývojáři {app_pro} intenzivně pracují na alternativních platebních možnostech, které by umožnily uživatelům zakoupit tarify {pro} mimo obchody {platform_store} a {platform_store}. Plán vývoje {pro} {icon} + Vrácení peněz za {app_pro} je vyřizováno výhradně prostřednictvím {platform} v obchodě {platform_store}.\n\nVzhledem k pravidlům vracení peněz služby {platform} nemají vývojáři {app_name} žádný vliv na výsledek žádostí o vrácení peněz. To zahrnuje i rozhodnutí, zda bude žádost schválena nebo zamítnuta, a také, zda bude vrácena část peněz, nebo všechny peníze. + Chcete znovu používat animované profilové obrázky?\nObnovte si přístup k {pro} pro odemknutí funkcí, které vám chyběly. + Prodloužit {pro} Beta + Prodlužte svůj přístup k {pro} v nastavení {app_pro} na propojeném zařízení s nainstalovanou aplikací {app_name} prostřednictvím {platform_store} nebo {platform_store_other}. + Chcete znovu posílat delší zprávy?\nObnovte si přístup k {pro} pro odemknutí funkcí, které vám chyběly. + Chcete znovu využít {app_name} naplno?\nProdlužte si přístup k {pro} pro odemknutí funkcí, které vám chyběly. + Chcete si znovu připnout více konverzací?\nProdlužte si přístup k {pro} pro odemknutí funkcí, které vám chyběly. + Prodloužením souhlasíte s Podmínkami služby {icon} a Zásadami ochrany osobních údajů {icon} {app_pro} + Prodloužení Pro selhalo, brzy proběhne další pokus + V současnosti lze přístup k {pro} zakoupit a prodloužit pouze prostřednictvím {platform_store} nebo {platform_store_other}. Protože jste nainstalovali {app_name} pomocí {build_variant}, nemůžete zde prodloužit přístup.\n\nVývojáři {app_name} intenzivně pracují na alternativních platebních možnostech, které by uživatelům umožnily zakoupit přístup k {pro} mimo {platform_store} a {platform_store_other}. Plán vývoje {pro} {icon} Žádost o vrácení peněz Posílejte více se Nastavení {pro} + Začít používat {pro} Vaše statistiky {pro} Načítání statistik {pro} Vaše statistiky {pro} se načítají, počkejte prosím. @@ -1064,16 +1169,25 @@ Protože jste si původně tarif {app_pro} aktivovali přes {platform_account Stav načítání {pro} Načítají se vaše informace {pro}. Některé akce na této stránce nemusí být dostupné, dokud nebude načítání dokončeno. stav načítání {pro} + Nelze se připojit k síti, aby bylo možné zkontrolovat váš stav {pro}. Dokud nebude obnoveno připojení, nelze pokračovat.\n\nZkontrolujte připojení k síti a zkuste to znovu. Nelze se připojit k síti, aby bylo možné zkontrolovat váš stav {pro}. Dokud nebude obnoveno připojení, nelze provést navýšení na {pro}.\n\nZkontrolujte připojení k síti a zkuste to znovu. Nelze se připojit k síti, aby se obnovil váš stav {pro}. Některé akce na této stránce budou deaktivovány, dokud nebude obnoveno připojení.\n\nZkontrolujte připojení k síti a zkuste to znovu. - Potřebujete pomoc se svým tarifem {pro}? Pošlete žádost týmu podpory. + Nelze se připojit k síti kvůli načtení vašeho aktuálního přístupu k {pro}. Obnovení {pro} prostřednictvím {app_name} bude deaktivováno, dokud nebude obnoveno připojení.\n\nZkontrolujte připojení k síti a zkuste to znovu. + Potřebujete pomoc s {pro}? Pošlete žádost týmu podpory. Aktualizací souhlasíte s Podmínkami služby {icon} a Zásadami ochrany osobních údajů {icon} {app_pro} Neomezený počet připnutí Organizujte si komunikaci pomocí neomezeného počtu připnutých konverzací. - V současné době jste na tarifu {current_plan}. Jste si jisti, že chcete přepnout na tarif {selected_plan}?\n\nPo aktualizaci bude váš tarif automaticky obnoven {date} na další {selected_plan} přístupu {pro}. - Váš tarif vyprší {date}.\n\nPo aktualizaci se váš tarif automaticky obnoví {date} na další {selected_plan} přístupu Pro. - Chcete z {app_name} získat více? Navyštee na {app_pro} pro výkonnější posílání zpráv. - {platform_account} zpracovává vaši žádost o vrácení peněz + Vaše aktuální fakturační možnost zahrnuje {current_plan_length} přístupu k {pro}. Jste si jisti, že chcete přejít na fakturační možnost {selected_plan_length_singular}?\n\nPo aktualizaci se váš přístup k {pro} automaticky prodlouží {date} na dalších {selected_plan_length} přístupu k {pro}. + Váš přístup k {pro} vyprší {date}.\n\nAktualizací se váš {pro} přístup automaticky prodlouží {date} na dalších {selected_plan_length} přístupu k {pro}. + Navyšte na {app_pro} Beta a získejte přístup k mnoha exkluzivním výhodám a funkcím. + Navyšte na {pro} v nastavení {app_pro} na propojeném zařízení s nainstalovanou {app_name} prostřednictvím {platform_store} nebo {platform_store_other}. + V současnosti lze přístup k {pro} zakoupit pouze prostřednictvím {platform_store} nebo {platform_store_other}. Protože jste nainstalovali {app_name} pomocí {build_variant}, nemůžete zde navýšit na {pro}.\n\nVývojáři {app_name} intenzivně pracují na alternativních platebních možnostech, které by uživatelům umožnily zakoupit přístup k {pro} mimo {platform_store} a {platform_store_other}. Plán vývoje {pro} {icon} + V tuto chvíli je k dispozici pouze jedna možnost navýšení: + Nyní jsou k dispozici dva způsoby navýšení: + Navýšili jste na {app_pro}!\n\nDěkujeme, že podporujete síť {network_name}. + Navýšení na {pro} + Navýšením souhlasíte s Podmínkami služby {icon} a Zásadami ochrany osobních údajů {icon} {app_pro} + {platform} zpracovává vaši žádost o vrácení peněz Profil Zobrazovaný obrázek Chyba při odstraňování zobrazovaného obrázku. @@ -1081,6 +1195,13 @@ Protože jste si původně tarif {app_pro} aktivovali přes {platform_account Prosím vyberte menší soubor. Nepodařilo se aktualizovat profil. Povýšit + Správci budou moci vidět historii zpráv za posledních 14 dní a nemohou být ponížení ani odebráni ze skupiny. + + Povýšit člena + Povýšit členy + Povýšit členy + Povýšit členy + Povýšení selhalo Povýšení selhala @@ -1133,7 +1254,8 @@ Protože jste si původně tarif {app_pro} aktivovali přes {platform_account Toto je vaše heslo pro obnovení. Pokud ho někomu pošlete, bude mít plný přístup k vašemu účtu. Znovu vytvořit skupinu Znovu - Protože jste se původně zaregistrovali do {app_pro} přes jiný {platform_account}, je třeba použít ten {platform_account}, abyste aktualizovali váš tarif. + Protože jste si původně zaregistrovali {app_pro} přes jiný {platform_account}, je třeba použít ten {platform_account}, abyste aktualizovali svůj přístup k {pro}. + Dva způsoby, jak požádat o vrácení platby: Zkraťte délku zprávy o {count} Zbývá %1$d znak @@ -1141,14 +1263,58 @@ Protože jste si původně tarif {app_pro} aktivovali přes {platform_account Zbývá %1$d znaků Zbývá %1$d znaků + Připomenout později Odstranit + + Odebrat člena + Odebrat členy + Odebrat členy + Odebrat členy + + + Odebrat člena a jeho zprávy + Odebrat členy a jejich zprávy + Odebrat členy a jejich zprávy + Odebrat členy a jejich zprávy + Odebrání hesla selhalo Odstraňte své aktuální heslo pro {app_name}. Lokálně uložená data budou znovu zašifrována pomocí náhodně vygenerovaného klíče uloženého ve vašem zařízení. - Obnovit - Obnovení Pro + + Odebírání člena + Odebírání členů + Odebírání členů + Odebírání členů + + Prodloužit + Prodlužování {pro} Odpovědět Požádat o vrácení platby + Požádejte o vrácení platby na webových stránkách {platform} pomocí účtu {platform_account}, kterým jste se zaregistrovali do {pro}. Odeslat znovu + + Znovu odeslat pozvánku + Znovu odeslat pozvánky + Znovu odeslat pozvánky + Znovu odeslat pozvánky + + + Znovu odeslat povýšení + Znovu odeslat povýšení + Znovu odeslat povýšení + Znovu odeslat povýšení + + + Opětovné odesílání pozvánky + Opětovné odesílání pozvánek + Opětovné odesílání pozvánek + Opětovné odesílání pozvánek + + + Opětovné odesílání povýšení + Opětovné odesílání povýšení + Opětovné odesílání povýšení + Opětovné odesílání povýšení + Načítám informace o zemi... Restartovat Znovu synchronizovat @@ -1251,6 +1417,9 @@ Protože jste si původně tarif {app_pro} aktivovali přes {platform_account Vrátit zpět Neznámé Nepodporovaný procesor + Aktualizovat + Aktualizovat přístup {pro} + Dva způsoby, jak aktualizovat váš přístup k {pro}: Aktualizace aplikace Upravit informace o komunitě Název a popis komunity jsou viditelné pro všechny členy komunity @@ -1265,8 +1434,6 @@ Protože jste si původně tarif {app_pro} aktivovali přes {platform_account Zadejte prosím kratší popis skupiny Je dostupná nová verze {app_name}, klikněte pro aktualizaci Je dostupná nová verze ({version}) aplikace {app_name}. - Aktualizovat tarif - Dva způsoby, jak aktualizovat váš tarif: Upravit informace profilu Vaše zobrazované jméno a profilová fotka jsou viditelné ve všech konverzacích. Přejít na poznámky k vydání @@ -1275,6 +1442,7 @@ Protože jste si původně tarif {app_pro} aktivovali přes {platform_account Naposledy aktualizováno před {relative_time} Aktualizace Aktualizuji... + Navýšení Navýšit {app_name} Navýšit na Nahrávání @@ -1284,8 +1452,9 @@ Protože jste si původně tarif {app_pro} aktivovali přes {platform_account Opravdu chcete otevřít tuto URL adresu ve vašem prohlížeči?\n\n{url} Odkazy se otevřou ve vašem prohlížeči. Použít rychlý režim - Přes webové stránky {platform_store} - Změňte svůj tarif pomocí {platform_account}, se kterým jste se zaregistrovali, prostřednictvím webu {platform_store}. + Změňte svůj tarif pomocí {platform_account}, kterým jste se zaregistrovali, prostřednictvím webu {platform}. + Přes webové stránky {platform} + Aktualizujte svůj přístup k {pro} pomocí {platform_account}, se kterým jste se zaregistrovali, prostřednictvím webu {platform_store}. Video Nelze přehrát video. Zobrazit @@ -1294,6 +1463,7 @@ Protože jste si původně tarif {app_pro} aktivovali přes {platform_account Může to trvat několik minut. Okamžik prosím... Varování + Podpora pro iOS 15 byla ukončena. Aktualizujte na iOS 16 nebo novější verzi, abyste mohli i nadále dostávat aktualizace aplikace. Okno Ano Vy diff --git a/app/src/main/res/values-b+da+DK/strings.xml b/app/src/main/res/values-b+da+DK/strings.xml index 9ddcd69515..609b56c225 100644 --- a/app/src/main/res/values-b+da+DK/strings.xml +++ b/app/src/main/res/values-b+da+DK/strings.xml @@ -14,7 +14,6 @@ Dette er din kontoid. Andre brugere kan scanne den for at starte en samtale med dig. Faktisk størrelse Tilføj - Tilføj administratorer Admins kan ikke fjernes. {name} og {count} andre blev forfremmet til Admin. Forfrem administratorer diff --git a/app/src/main/res/values-b+de+DE/strings.xml b/app/src/main/res/values-b+de+DE/strings.xml index a7a4420610..bbe7a62045 100644 --- a/app/src/main/res/values-b+de+DE/strings.xml +++ b/app/src/main/res/values-b+de+DE/strings.xml @@ -15,7 +15,6 @@ Dies ist deine Account-ID. Andere Benutzer können sie scannen, um eine Unterhaltung mit dir zu beginnen. Originalgröße Hinzufügen - Administratoren hinzufügen Gib die Account-ID des Nutzers ein, den du zum Administrator ernennst.\n\nUm mehrere Nutzer hinzuzufügen, gib jede Account-ID durch ein Komma getrennt ein. Es können bis zu 20 Account-IDs gleichzeitig angegeben werden. Admins können nicht entfernt werden. {name} und {count} andere wurden zu Admin befördert. @@ -871,14 +870,10 @@ Aktiviert Du hast bereits Lade GIF- und animierte WebP-Bilder als Profilbild hoch! - Hole dir animierte Profilbilder und schalte Premium-Funktionen mit {app_pro} frei Animiertes Profilbild Nutzer können GIFs hochladen GIFs hochladen mit Automatische {pro} Erneuerung in {time} - Du möchtest längere Nachrichten senden? Sende mehr Text und schalte Premium-Funktionen mit {app_pro} frei - Mehr Anheftungen gewünscht? Organisiere deine Chats und schalte Premium-Funktionen mit {app_pro} frei - Mehr als 5 Anheftungen gewünscht? Organisiere deine Chats und schalte Premium-Funktionen mit {app_pro} frei Abgelaufen {pro} FAQ GIF- und WebP-Profilbilder hochladen @@ -894,7 +889,6 @@ Mehr senden mit Deine {pro} Statistik Durch die Aktualisierung stimmst du den Nutzungsbedingungen {icon} und der Datenschutzerklärung {icon} von {app_pro} zu - Willst du mehr aus {app_name} herausholen? Upgrade auf {app_pro} für ein leistungsstärkeres Nachrichten-Erlebnis. Profil Anzeigebild Fehler beim Entfernen des Profilbildes. diff --git a/app/src/main/res/values-b+eo+UY/strings.xml b/app/src/main/res/values-b+eo+UY/strings.xml index 7ba955f941..016f615bb6 100644 --- a/app/src/main/res/values-b+eo+UY/strings.xml +++ b/app/src/main/res/values-b+eo+UY/strings.xml @@ -15,7 +15,6 @@ Ĉi tio estas via Konto IDENT. Aliaj uzantoj povas skani ĝin por komenci konversacion kun vi. Efektiva grandeco Aldoni - Aldoni administrantojn Administrantoj ne povas esti forigitaj. {name} kaj {count} aliaj estis promociitaj al Admin. Promocii administrantojn diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 70e1a031f2..71f181b145 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -15,7 +15,6 @@ Este es tu Account ID. Otros usuarios pueden escanearlo para iniciar una conversación contigo. Tamaño original Añadir - Añadir Administradores Ingrese el Account ID del usuario que desea promover a administrador.\n\nPara agregar varios usuarios, ingrese cada Account ID separado por una coma. Puede especificar hasta 20 Account ID a la vez. Los administradores no pueden ser eliminados. {name} y {count} más fueron promovidos a Admin. @@ -826,13 +825,9 @@ Activado Ya tienes ¡Adelante, sube GIFs e imágenes WebP animadas para tu imagen de perfil! - Consigue imágenes de perfil animadas y desbloquea funciones premium con {app_pro} Imagen de perfil animada los usuarios pueden subir GIFs Sube GIFs con - ¿Quieres enviar mensajes más largos? Envía más texto y desbloquea funciones premium con {app_pro} - ¿Quieres más conversaciones fijadas? Organiza tus chats y desbloquea funciones premium con {app_pro} - ¿Quieres más de 5 conversaciones fijadas? Organiza tus chats y desbloquea funciones premium con {app_pro} Sube imágenes de perfil en formato GIF y WebP Chats grupales más grandes de hasta 300 miembros Y muchas funciones exclusivas más @@ -844,7 +839,6 @@ Mayor longitud de mensaje Este mensaje utilizó las siguientes funciones de {app_pro}: Envía más con - ¿Quieres sacarle más provecho a {app_name}? Actualiza a {app_pro} para una experiencia de mensajería más potente. Perfil Imagen de perfil Falló al remover foto de perfil. diff --git a/app/src/main/res/values-b+es+ES/strings.xml b/app/src/main/res/values-b+es+ES/strings.xml index d2c8bfadf6..7dcd1c3202 100644 --- a/app/src/main/res/values-b+es+ES/strings.xml +++ b/app/src/main/res/values-b+es+ES/strings.xml @@ -15,7 +15,6 @@ Este es tu ID de cuenta. Otros usuarios pueden escanearlo para iniciar una conversación contigo. Tamaño original Añadir - Añadir Administradores Ingrese el Account ID del usuario que desea promover a administrador.\n\nPara agregar varios usuarios, ingrese cada Account ID separado por una coma. Puede especificar hasta 20 Account ID a la vez. Los administradores no pueden ser eliminados. {name} y {count} más fueron promovidos a Administradores. @@ -826,13 +825,9 @@ Activado Ya tienes ¡Adelante, sube GIFs e imágenes WebP animadas para tu imagen de perfil! - Consigue imágenes de perfil animadas y desbloquea funciones premium con {app_pro} Imagen de perfil animada los usuarios pueden subir GIFs Sube GIFs con - ¿Quieres enviar mensajes más largos? Envía más texto y desbloquea funciones premium con {app_pro} - ¿Quieres más conversaciones fijadas? Organiza tus chats y desbloquea funciones premium con {app_pro} - ¿Quieres más de 5 conversaciones fijadas? Organiza tus chats y desbloquea funciones premium con {app_pro} Sube imágenes de perfil en formato GIF y WebP Chats grupales más grandes de hasta 300 miembros Y muchas funciones exclusivas más @@ -844,7 +839,6 @@ Mayor longitud de mensaje Este mensaje utilizó las siguientes funciones de {app_pro}: Envía más con - ¿Quieres sacarle más provecho a {app_name}? Actualiza a {app_pro} para una experiencia de mensajería más potente. Perfil Imagen de perfil Fallo al eliminar la foto de perfil. diff --git a/app/src/main/res/values-b+fa+IR/strings.xml b/app/src/main/res/values-b+fa+IR/strings.xml index 16d174fa19..c8abbddb06 100644 --- a/app/src/main/res/values-b+fa+IR/strings.xml +++ b/app/src/main/res/values-b+fa+IR/strings.xml @@ -270,12 +270,20 @@ پیام را پاک کنید پیام ها را پاک کنید + + آیا مطمئن هستید که می‌خواهید این پیام را حذف کنید؟ + آیا مطمئن هستید که می‌خواهید این پیام‌ها را حذف کنید؟ + پیام پاک شد پبام ها حذف شدند این پیام حذف شده است این پیام در این دستگاه پاک شد + + آیا مطمئن هستید که میخواهید این پیام را فقط از این دستگاه حذف کنید؟ + آیا مطمئن هستید که میخواهید همه پیامها را فقط از این دستگاه حذف کنید؟ + آیا مطمئن هستید که می‌خواهید این پیام را برای همه حذف کنید؟ حذف فقط از این دستگاه حذف از تمام دستگاه‌هایم diff --git a/app/src/main/res/values-b+fr+FR/strings.xml b/app/src/main/res/values-b+fr+FR/strings.xml index 07d92266ed..65cafef92c 100644 --- a/app/src/main/res/values-b+fr+FR/strings.xml +++ b/app/src/main/res/values-b+fr+FR/strings.xml @@ -15,8 +15,12 @@ Ceci est votre ID de compte. Les autres utilisateurs peuvent le scanner pour démarrer une conversation avec vous. Taille réelle Ajouter - Ajouter des administrateurs + + Ajouter un administrateur + Ajouter des administrateurs + Entrez l\'identifiant du compte de l\'utilisateur que vous souhaitez promouvoir en administrateur.\n\nPour ajouter plusieurs utilisateurs, saisissez chaque identifiant de compte séparé par une virgule. Vous pouvez spécifier jusqu\'à 20 identifiants à la fois. + Les admins ne peuvent pas être rétrogradés ou retirés du groupe. Les administrateurs ne peuvent pas être supprimés. {name} et {count} autres ont été promus en tant qu\'administrateurs. Promouvoir les administrateurs @@ -41,12 +45,18 @@ {name} a été retiré en tant qu\'administrateur. {name} et {count} autres ont été supprimé·e·s en tant qu\'administrateur. {name} et {other_name}ont été supprimé·e·s en tant qu\'administrateur. + + %1$d Administrateur sélectionné + %1$d Administrateurs sélectionnés + Envoi de la promotion administrateur Envoi de la promotion administrateur Paramètres d’administrateur + Vous ne pouvez pas modifier votre statut d’administrateur. Pour quitter le groupe, ouvrez les paramètres de la conversation et sélectionnez Quitter le groupe. {name} et {other_name} ont été promus en tant qu\'administrateurs. + Administrateurs +{count} Anonyme Icône de l’app @@ -191,6 +201,10 @@ Active les appels vocaux et vidéo vers et depuis d\'autres utilisateurs. Vous avez appelé {name} Vous avez manqué un appel de {name} car vous n\'avez pas activé Appels vocaux et vidéo dans les Paramètres de confidentialité. + {app_name} a besoin d\'accéder à votre caméra pour activer les appels vidéo, mais cette autorisation a été refusée. Vous ne pouvez pas modifier les autorisations de la caméra pendant un appel.\n\n Souhaitez-vous terminer l\'appel maintenant et activer l\'accès à la caméra, ou préférez-vous recevoir un rappel après l\'appel ? + Pour autoriser l\'accès à la caméra, ouvrez les paramètres et activez l\'autorisation caméra. + Lors de votre dernier appel, vous avez tenté d’utiliser la vidéo, mais cela n’a pas été possible car l’accès à la caméra avait été précédemment refusé. Pour autoriser l’accès à la caméra, ouvrez les paramètres et activez l’autorisation caméra. + Accès à la caméra requis Aucune caméra trouvée L’appareil photo n’est pas disponible. Autoriser l\'accès à la caméra @@ -198,10 +212,19 @@ {app_name} a besoin de l’autorisation Caméra pour prendre des photos ou des vidéos, ou scanner des codes QR. {app_name} a besoin d\'accéder à l\'appareil photo pour scanner les codes QR Annuler - Annuler l’abonnement + Résilier l\'accès {pro} + Annuler l’abonnement {pro} + Annulez sur le site Web de {platform}, en utilisant le compte {platform_account} avec lequel vous vous êtes inscrit à {pro}. + Annulez sur le site Web de {platform_store}, en utilisant le compte {platform_account} avec lequel vous vous êtes inscrit à {pro}. Modifier Échec de changement de mot de passe Changez votre mot de passe pour {app_name}. Les données stockées localement seront re-chiffrées avec votre nouveau mot de passe. + Vérification du statut {pro} + Vérification de votre statut {pro}. Vous pourrez continuer une fois cette vérification terminée. + Vérifications de vos informations {pro}. Certaines actions de cette page peuvent être indisponible jusqu\'à la fin de la vérification. + Vérification du statut {pro}... + Vérification de vos informations {pro}. Vous ne pouvez pas renouveler tant que cette vérification n\'est pas terminée. + Vérification de votre statut {pro}. Vous pourrez passer à {pro} une fois cette vérification terminée. Effacer Effacer tout Effacer toutes les données @@ -260,11 +283,17 @@ URL de la communauté Copier l\'URL de la communauté Confirmer + Confirmer la promotion + Êtes-vous sûr ? Les administrateurs ne peuvent pas être rétrogradés ou supprimés du groupe. Contacts Supprimer le contact Êtes-vous sûr·e de vouloir supprimer {name} de vos contacts? Les nouveaux messages de {name} arriveront sous forme de demande de message. Vous n\'avez pas encore de contacts Sélectionner des contacts + + %1$d Contact sélectionné + %1$d Contacts sélectionnés + Détails de l\'utilisateur Caméra Choisissez une action pour démarrer une conversation @@ -305,8 +334,8 @@ Copier Créer Création de l\'appel + Facture actuelle Mot de passe actuel - Forfait actuel Couper Mode sombre Êtes-vous sûr de vouloir supprimer tous les messages, pièces jointes et données de compte de cet appareil et créer un nouveau compte ? @@ -333,6 +362,14 @@ Veuillez patienter pendant la création du groupe... Échec de la mise à jour du groupe Vous n\'êtes pas autorisé à supprimer les messages des autres utilisateurs + + Supprimer la pièce jointe sélectionnée + Supprimer les pièces jointes sélectionnées + + + Êtes-vous certain de vouloir effacer la pièce jointe sélectionnée? Le message associé à la pièce jointe sera également supprimé. + Êtes-vous certain de vouloir effacer toutes les pièces jointes sélectionnées ? Les messages associés aux pièces jointes seront également supprimés. + Êtes-vous sûr de vouloir supprimer {name} de vos contacts ?\n\nCela supprimera votre conversation, y compris tous les messages et pièces jointes. Les futurs messages de {name} apparaîtront comme une demande de message. Êtes-vous sûr de vouloir supprimer votre conversation avec {name} ?\nCela supprimera définitivement tous les messages et pièces jointes. @@ -448,17 +485,29 @@ Vous et {name} avez réagi avec {emoji_name} A réagi à votre message {emoji} Activer + Activer l\'accès à la caméra ? Afficher les notifications lorsque vous recevez de nouveaux messages. + Terminer l\'appel pour activer Vous aimez {app_name} ? Des améliorations seraient utiles {emoji} C’est génial {emoji} Vous utilisez {app_name} depuis un petit moment, comment ça se passe ? Nous aimerions beaucoup connaître votre avis. Entrer + Saisissez le mot de passe que vous avez défini pour {app_name} + Saisissez le mot de passe que vous utilisez pour déverrouiller {app_name} au démarrage, et non votre mot de passe de récupération + Erreur lors de la vérification du statut {pro} Veuillez vérifier votre connexion internet et réessayer. Copier l\'erreur et quitter Erreur de base de données Une erreur s\'est produite. Veuillez réessayer plus tard. + Erreur lors du chargement de l’accès {pro} + {app_name} n\'a pas pu rechercher cet ONS. Veuillez vérifier votre connexion réseau et réessayer. Une erreur inconnue est survenue. + Cet ONS n\'est pas enregistré. Veuillez vérifier qu\'il est correct et réessayer. + Échec du renvoi de l\'invitation à {name} dans {group_name} + Échec du renvoi de {name} et de {count} autres dans {group_name} + Échec du renvoi de l\'invitation à {name} et {other_name} dans {group_name} + Échec du renvoi de l\'invitation à {name} et {other_name} dans {group_name} Échec du téléchargement Échecs Donner votre avis @@ -512,6 +561,9 @@ Êtes-vous sûr de vouloir quitter {group_name} ? Êtes-vous certain de vouloir quitter {group_name}?\n\n Cela supprimera tous les membres et tout le contenu du groupe. Échec de quitter {group_name} + {name} a été invité·e à rejoindre le groupe. L\'historique des 14 derniers jours a été partagé. + {name} et {count} autres ont été invités à rejoindre le groupe. L\'historique de conversation des 14 derniers jours a été partagé. + {name} et {other_name} ont été invités à rejoindre le groupe. L\'historique des discussions des 14 derniers jours a été partagé. {name} a quitté le groupe. {name} et {count} autres ont quitté le groupe. {name} et {other_name} ont quitté le groupe. @@ -536,6 +588,7 @@ Vous n\'avez aucun message de {group_name}. Envoyez un message pour démarrer la conversation ! Ce groupe n\'a pas été mis à jour depuis plus de 30 jours. Vous pourriez rencontrer des problèmes pour envoyer des messages ou afficher les informations du groupe. Vous êtes le seul administrateur de {group_name}.\n\nLes membres du groupe et les paramètres ne peuvent pas être modifiés sans un administrateur. + Vous êtes le seul administrateur de {group_name}.\n\n Les membres du groupe et les paramètres ne peuvent pas être modifiés sans un administrateur. Pour quitter le groupe sans le supprimer, veuillez d\'abord ajouter un nouvel administrateur En attente de suppression Vous avez été promu·e en tant qu\'administrateur. Vous et {count} autres ont été promus admin. @@ -585,6 +638,10 @@ Demander le mode incognito, si disponible. Selon le clavier que vous utilisez, votre clavier peut ignorer cette demande. Info Raccourci invalide + + Inviter un contact + Inviter des contacts + Échec de l\'invitation Échec de l\'invitation @@ -593,6 +650,11 @@ L\'invitation n\'a pas pu être envoyée. Voulez-vous réessayer ? Les invitations n\'ont pas pu être envoyées. Voulez-vous réessayer ? + + Inviter un membre + Inviter des membres + + Invitez un nouveau membre au groupe en entrant l\'ID de compte, l\'ONS ou en scannant le code QR {icon} de ce membre Rejoindre Plus tard Lancer automatiquement {app_name} au démarrage de votre ordinateur. @@ -636,10 +698,15 @@ Appuyez pour déverrouiller {app_name} est déverrouillé Journaux + Gérer les administrateurs Gérer Membres Gérer {pro} Maximale Médias + + %1$d Membre sélectionné + %1$d Membres sélectionnés + %1$d membre %1$d membres @@ -650,6 +717,7 @@ Ajouter un ID de compte ou ONS Inviter des amis + Vous n’avez aucun contact à inviter dans ce groupe.\n Revenez en arrière et invitez des membres en utilisant leur identifiant de compte ou leur ONS. Envoyer l\'invitation Envoyer les invitations @@ -660,6 +728,7 @@ Partager l\'historique des messages Partager seulement les nouveaux messages Inviter + Membres (non-administrateurs) Barre de menu Message Lire plus @@ -678,6 +747,7 @@ Démarrez une nouvelle conversation en entrant l\'ID de compte ou l\'ONS de votre ami. Démarrez une nouvelle conversation en entrant l\'ID de compte, l\'ONS de votre ami ou en scannant leur code QR. + Démarrez une nouvelle conversation en entrant l\'ID de compte, l\'ONS ou en scannant le code QR {icon} de votre ami Vous avez un nouveau message. Vous avez %1$d nouveaux messages. @@ -735,7 +805,10 @@ Supprimer le surnom Définir un surnom Non + Il n\'y a aucun membre non-administrateur dans ce groupe. Pas de suggestions + Vous pouvez envoyer des messages d\'une taille maximale de 10 000 caractères dans toutes les conversations. + Organisez vos discussions avec un nombre illimité de conversations épinglées. Aucun Pas maintenant Note à mon intention @@ -786,7 +859,11 @@ Okay Activé Sur votre appareil {device_type} - Ouvrez ce compte {app_name} sur un appareil {device_type} connecté au compte {platform_account} avec lequel vous vous êtes inscrit à l\'origine. Ensuite, modifiez votre abonnement via les paramètres {app_pro}. + Ouvrez ce compte {app_name} sur un appareil {device_type} connecté au compte {platform_account} avec lequel vous vous êtes inscrit à l\'origine. Ensuite, annulez {pro} via les paramètres {app_pro}. + Ouvrez ce compte {app_name} sur un appareil {device_type} connecté au compte {platform_account} avec lequel vous vous êtes inscrit à l\'origine. Ensuite, modifiez votre accès {pro} via les paramètres {app_pro}. + Sur un appareil lié + Sur le site Web {platform_store} + Sur le site Web {platform} Créer un compte Compte Créé J\'ai un compte @@ -811,7 +888,9 @@ Nous n\'avons pas pu reconnaître cet ONS. Veuillez vérifier et réessayer. Nous n\'avons pas pu rechercher cet ONS. Veuillez réessayer plus tard. Ouvrir - Ouvrir le site Web de {platform_store} + Ouvrir le site Web de {platform_store} + Ouvrir le site Web de {platform} + Ouvrir les paramètres Ouvrir le questionnaire Autre Mot de passe @@ -888,12 +967,32 @@ Préférences Aperçu Aperçu de la notification + Votre accès {pro} est actif ! \n\nVotre accès {pro} sera automatiquement renouvelé pour une période de {current_plan_length} le {date}. + Votre accès {pro} expirera le {date}.\n\n Mettez à jour votre accès {pro} dès maintenant pour vous assurer qu\'il se renouvelle automatiquement avant {pro} expiration. + Votre accès {pro} est actif !\n\n Votre accès {pro} sera automatiquement renouvelé pour une période de \n{current_plan_length} le {date}. Toute modification effectuée ici prendra effet lors de votre prochain renouvellement. + Erreur d’accès {pro} + Votre accès {pro} expirera le {date}. + Chargement de l’accès {pro} + Les informations de votre accès {pro} sont encore en cours de chargement. Vous ne pouvez pas effectuer de mise à jour tant que ce processus n’est pas terminé. + Chargement de l’accès {pro}... + Impossible de se connecter au réseau pour charger vos informations d\'accès {pro}. La mise à jour de {pro} via {app_name} sera désactivée jusqu\'à ce que la connexion soit rétablie.\n\nVeuillez vérifier votre connexion réseau et réessayer. + Accès {pro} introuvable + {app_name} a détecté que votre compte ne dispose pas d’un accès {pro}. Si vous pensez qu’il s’agit d’une erreur, veuillez contacter l’assistance de {app_name} pour obtenir de l’aide. + Récupérer l’accès à {pro} + Renouveler l’accès {pro} + Actuellement, les accès {pro} ne peuvent être achetés et renouvelés que via les boutiques {platform_store} et {platform_store_other}. Étant donné que vous utilisez {app_name} Bureau, vous ne pouvez pas renouveler votre abonnement ici.\n\nLes développeurs de {app_name} travaillent activement sur des options de paiement alternatives pour permettre aux utilisateurs d\'acheter des abonnements {pro} en dehors des boutiques {platform_store} et {platform_store_other}. Feuille de route {pro} {icon} + Renouvelez votre accès {pro} sur le site Web de {platform_store} en utilisant le compte {platform_account} avec lequel vous vous êtes inscrit à {pro}. + Renouvelez sur le site Web de {platform}, en utilisant le compte {platform_account} avec lequel vous vous êtes inscrit à {pro}. + Renouvelez votre abonnement {pro} pour recommencer à utiliser les puissantes fonctionnalités bêta de {app_pro}. + Accès {pro} restauré + {app_name} a détecté et récupéré l\'accès {pro} de votre compte. Votre statut {pro} a été restauré ! + Comme vous vous êtes initialement inscrit à {app_pro} via {platform_store}, vous devez utiliser votre compte {platform_account} pour mettre à jour votre accès {pro}. + Actuellement, les accès {pro} ne peuvent être achetés que via les boutiques {platform_store} et {platform_store_other}. Étant donné que vous utilisez {app_name} Bureau, vous ne pouvez pas effectuer de mise à niveau vers {pro} ici.\n\nLes développeurs de {app_name} travaillent activement sur des options de paiement alternatives pour permettre aux utilisateurs dʼacheter des abonnements {pro} en dehors des boutiques {platform_store} et {platform_store_other}. Feuille de route {pro} {icon} Activé Tout est prêt ! - Votre forfait {app_pro} a été mis à jour ! Vous serez facturé lorsque votre forfait {pro} actuel sera automatiquement renouvelé le {date}. + Votre accès {app_pro} a été mis à jour ! Vous serez facturé lorsque {pro} sera automatiquement renouvelé le {date}. Vous avez déjà Téléchargez des GIF et des images WebP animées pour votre photo de profil ! - Obtenez des photos de profil animées et débloquez des fonctionnalités premium avec {app_pro} Photo de profil animée les utilisateurs peuvent télécharger des GIFs Photos de profil animées @@ -912,14 +1011,19 @@ {price} facturé annuellement {price} facturé mensuellement {price} facturé trimestriellement - Vous voulez envoyer des messages plus longs ? Envoyez plus de messages et débloqué les fonctionnalités premium avec {app_pro} - Vous voulez plus de messages épinglés ? Organisez vos chats et débloquez les fonctionnalités premium avec {app_pro} - Vous voulez plus que 5 messages épinglés ? Organisez vos chats et débloquez les fonctionnalités premium avec {app_pro} - Votre abonnement actuel bénéficie déjà d\'une remise de {percent}% sur le prix de {app_pro}. + Nous sommes désolés de vous voir annuler {pro}. Voici ce que vous devez savoir avant d\'annuler votre accès {pro}. + Annulation + L\'annulation de l\'accès {pro} empêchera le renouvellement automatique avant l\'expiration de l\'accès {pro}. L\'annulation de {pro} n\'entraîne pas de remboursement. Vous pourrez continuer à utiliser les fonctionnalités {app_pro} jusqu\'à l\'expiration de votre accès {pro}.\n\nComme vous vous êtes initialement inscrit à {app_pro} avec votre {platform_account}, vous devrez utiliser le même compte {platform_account} pour annuler {pro}. + Deux façons d’annuler votre accès {pro} : + L\'annulation de l\'accès {pro} empêchera le renouvellement automatique avant l\'expiration de {pro}.\n\nL\'annulation de {pro} n\'entraîne pas de remboursement. Vous pourrez continuer à utiliser les fonctionnalités {app_pro} jusqu\'à l\'expiration de votre accès {pro}. + Choisissez l\'accès {pro} qui vous convient.\nUn plan plus long offre des réductions plus importantes. + Êtes-vous sûr de vouloir supprimer vos données de cet appareil ?\n\n{app_pro} ne peut pas être transférée vers un autre compte. Veuillez enregistrer votre mot de passe de récupération afin d’assurer que vous pourrez restaurer votre accès {pro} ultérieurement. + Êtes-vous sûr de vouloir supprimer vos données du réseau ? Si vous continuez, vous ne pourrez pas restaurer vos messages ni vos contacts.\n\n{app_pro} ne peut pas être transféré vers un autre compte. Veuillez enregistrer votre mot de passe de récupération afin de garantir que vous pourrez restaurer votre accès {pro} ultérieurement. + Votre accès {pro} bénéficie déjà d\'une remise de {percent}% sur le prix normal de {app_pro}. + Erreur lors de l\'actualisation du statut {pro} Expiré - Malheureusement, votre formule {pro} a expiré. Renouvelez-la pour continuer à profiter des avantages et fonctionnalités exclusifs de {app_pro}. + Malheureusement, votre accès {pro} a expiré.\nRenouvelez-le pour réactiver les avantages et fonctionnalités exclusifs de {app_pro} Beta. Expiration imminente - Votre formule {pro} expire dans {time}. Mettez à jour votre formule pour continuer à profiter des avantages et fonctionnalités exclusifs de {app_pro}. {pro} expire dans {time} FAQ {pro} Trouvez des réponses aux questions fréquentes dans la FAQ de {app_pro}. @@ -928,17 +1032,19 @@ Plus offre des fonctions exclusives additionnelles Messages jusqu\'à 10,000 caractères Épinglez un nombre illimité de conversations + Vous voulez tirer le meilleur parti de {app_name} ?\n Passez à {app_pro} Bêta. Groupe activé Ce groupe a une capacité étendue ! Il peut contenir jusqu’à 300 membres grâce à un administrateur qui dispose de %1$s groupe mis à niveau %1$s groupes mis à niveau - La demande de remboursement est définitive. Si elle est approuvée, votre formule {pro} sera immédiatement annulée et vous perdrez l\'accès à toutes les fonctionnalités {pro}. + Une demande de remboursement est définitive. Si elle est approuvée, votre accès {pro} sera immédiatement annulé et vous perdrez l\'accès à toutes les fonctionnalités {pro}. Taille de pièce jointe augmentée Longueur de message augmentée Groupes plus grands Les groupes dont vous êtes administrateur sont automatiquement mis à niveau pour prendre en charge 300 membres. + Les discussions de groupe plus larges (jusqu\'à 300 membres) arrivent bientôt pour tous les utilisateurs Pro Bêta ! Messages plus longs Vous pouvez envoyer des messages jusqu\'à 10000 caractères dans toutes les conversations. @@ -946,52 +1052,71 @@ %1$s Messages plus longs envoyés Ce message utilise les fonctionnalités suivantes de {app_pro} : + Avec une nouvelle installation + Réinstallez {app_name} sur cet appareil via le {platform_store}, restaurez votre compte avec votre mot de passe de récupération, puis renouvelez {pro} dans les paramètres de {app_pro}. + Réinstallez {app_name} sur cet appareil via le {platform_store}, restaurez votre compte avec votre mot de passe de récupération, puis passez à {pro} depuis les paramètres de {app_pro}. + Pour l\'instant, il existe trois moyens de renouveler : + Pour l\'instant, il existe deux moyens de renouveler votre accès : {percent}% de réduction %1$s Conversation épinglée %1$s Conversations épinglées - Votre formule {app_pro} est active !\n\nVotre formule sera automatiquement renouvelée pour une autre {current_plan} le {date}. Les modifications apportées à votre formule prendront effet lors du prochain renouvellement de {pro}. - Votre forfait {app_pro} est actif\n\nVotre abonnement se renouvellera automatiquement pour un autre {current_plan} le {date}. - Votre offre {app_pro} expirera le {date}.\n\nMettez votre offre à jour maintenant pour garantir un accès ininterrompu aux fonctionnalités exclusives Pro. - Votre forfait {app_pro} expirera le {date}. - Forfait {pro} introuvable - Aucun forfait actif n’a été trouvé pour votre compte. Si vous pensez qu’il s’agit d’une erreur, veuillez contacter l’assistance de {app_name} pour obtenir de l’aide. Parce que vous avez initialement souscrit {app_pro} depuis {platform_store}, vous devrez utiliser le même {platform_account} pour demander un remboursement. Comme vous vous êtes initialement inscrit à {app_pro} via le Store {platform_store}, votre demande de remboursement sera traitée par le support de {app_name}.\n\nDemandez un remboursement en cliquant sur le bouton ci-dessous et en remplissant le formulaire de demande de remboursement.\n\nBien que le support de {app_name} s\'efforce de traiter les demandes de remboursement sous 24-72 heures, le traitement peut prendre plus de temps en cas de forte demande. - Récupérer le forfait {pro} - Renouveler l’abonnement {pro} - Actuellement, les abonnements {pro} ne peuvent être achetés et renouvelés que via les boutiques {platform_store} et {platform_store}. Étant donné que vous utilisez {app_name} Desktop, vous ne pouvez pas renouveler votre abonnement ici.\n\nLes développeurs de {app_pro} travaillent activement sur des options de paiement alternatives pour permettre aux utilisateurs d\'acheter des abonnements {pro} en dehors des boutiques {platform_store} et {platform_store}. Feuille de route de {pro} {icon} - Renouvelez votre abonnement dans les paramètres de {app_pro} sur un appareil lié avec {app_name} installé via le {platform_store} ou le magasin {platform_store}. - Renouvelez votre abonnement sur le site Web de {platform_store} en utilisant le compte {platform_account} avec lequel vous vous êtes inscrit à {pro}. - Renouvelez votre abonnement {app_pro} pour recommencer à utiliser les puissantes fonctionnalités bêta de {app_pro}. - Votre forfait {app_pro} a été renouvelé ! Merci de soutenir le {network_name}. - {pro} Forfait rétabli - Un abonnement valide pour {app_pro} a été détecté et votre statut {pro} a été restauré ! - Comme vous vous êtes initialement inscrit à {app_pro} via le {platform_store}, vous devez utiliser votre compte {platform_account} pour mettre à jour votre abonnement. + Votre forfait {app_pro} a été renouvelé ! Merci de soutenir {network_name}. 1 mois – {monthly_price} / mois 3 mois - {monthly_price} / mois 12 mois - {monthly_price} / mois + Ouvrez ce compte {app_name} sur un appareil {device_type} connecté au compte {platform_account} avec lequel vous vous êtes inscrit à l\'origine. Ensuite, demandez un remboursement via les paramètres {app_pro}. Nous sommes désolés de vous voir partir. Voici ce que vous devez savoir avant de demander un remboursement. - {platform_account} traite actuellement votre demande de remboursement. Cela prend généralement 24-48 heures. Selon leur décision, votre statut {pro} peut changer dans {app_name}. + {platform} traite actuellement votre demande de remboursement. Cela prend généralement 24-48 heures. Selon leur décision, votre statut {pro} peut changer dans {app_name}. Votre demande de remboursement sera traitée par le service de {app_name}.\n\nDemandez un remboursement en appuyant sur le bouton ci-dessous et en remplissant le formulaire de demande de remboursement.\n\nBien que le service de {app_name} fait au mieux afin de traiter les demandes de remboursement dans un délai de 24-72 heures, le traitement peut être plus long en période de forte demande. - Votre demande de remboursement sera traitée exclusivement par {platform_account} via le site Web de {platform_account}.\n\nEn raison des politiques de remboursement de {platform_account}, les développeurs de {app_name} n\'ont aucun moyen d\'influencer l\'issue des demandes de remboursement. Cela inclut la décision d\'approuver ou de refuser la demande, ainsi que l\'éventualité d\'un remboursement total ou partiel. - Veuillez contacter {platform_account} pour obtenir des mises à jour concernant votre demande de remboursement. En raison des politiques de remboursement de {platform_account}, les développeurs de {app_name} n\'ont aucune possibilité d\'influencer le résultat des demandes de remboursement.\n\nAssistance pour les remboursements de {platform_store}. + Votre demande de remboursement sera traitée exclusivement par {platform} via le site Web de {platform}.\n\nEn raison des politiques de remboursement de {platform}, les développeurs de {app_name} n\'ont aucun moyen d\'influencer l\'issue des demandes de remboursement. Cela inclut la décision d\'approuver ou de refuser la demande, ainsi que l\'éventualité d\'un remboursement total ou partiel. + Veuillez contacter {platform} pour obtenir des mises à jour concernant votre demande de remboursement. En raison des politiques de remboursement de {platform}, les développeurs de {app_name} n\'ont aucune possibilité d\'influencer le résultat des demandes de remboursement.\n\nAssistance pour les remboursements de {platform}. Remboursement de {pro} - Les remboursements pour les forfaits {app_pro} sont exclusivement gérés par {platform_account} via le Store {platform_store}.\n\nConformément aux politiques de remboursement de {platform_account}, les développeurs de {app_name} n\'ont aucun pouvoir d\'influencer l\'issue des demandes de remboursement. Cela inclut la décision d\'approbation ou de refus, ainsi que le montant total ou partiel du remboursement. + Les remboursements pour {app_pro} sont exclusivement gérés par {platform} via {platform_store}.\n\nConformément aux politiques de remboursement de {platform}, les développeurs de {app_name} n\'ont aucun pouvoir d\'influencer l\'issue des demandes de remboursement. Cela inclut la décision d\'approbation ou de refus, ainsi que le montant total ou partiel du remboursement. + Envie d’utiliser à nouveau des photos de profil animées ?\n Renouvelez votre accès {pro} pour débloquer les fonctionnalités qui vous manquent. + Renouveler la version bêta {pro} + Renouvelez votre accès {pro} dans les paramètres de {app_pro} sur un appareil lié avec {app_name} installé via {platform_store} ou {platform_store_other}. + Envie d’envoyer à nouveau des messages plus longs ?\n Renouvelez votre accès {pro} pour débloquer les fonctionnalités qui vous manquent. + Envie d’utiliser {app_name} à son plein potentiel à nouveau ?\n Renouvelez votre accès {pro} pour débloquer les fonctionnalités qui vous manquent. + Envie d’épingler à nouveau plus de conversations ?\n Renouvelez votre accès {pro} pour débloquer les fonctionnalités qui vous manquent. + En renouvelant, vous acceptez les Conditions d\'utilisation {icon} et la Politique de confidentialité {icon} de {app_pro} + Renouvellement Pro échoué, nouvelle tentative bientôt + Actuellement, les accès {pro} ne peuvent être achetés et renouvelés que via les boutiques {platform_store} et {platform_store_other}. Étant donné que vous avez installé {app_name} à l\'aide de la version {build_variant}, vous ne pouvez pas renouveler votre abonnement ici.\n\nLes développeurs de {app_name} travaillent activement sur des options de paiement alternatives pour permettre aux utilisateurs d\'acheter des abonnements {pro} en dehors des boutiques {platform_store} et {platform_store_other}. Feuille de route {pro}{icon} Remboursement demandé Envoyez plus avec Paramètres {pro} + Commencez à utiliser {pro} Vos statistiques {pro} + Chargement des statistiques {pro} + Vos statistiques {pro} sont en cours de chargement, veuillez patienter. Les statistiques {pro} reflètent l\'utilisation sur cet appareil et peuvent apparaître différemment sur les appareils connectés - Besoin d\'aide avec votre forfait {pro} ? Envoyez une demande à l\'équipe d\'assistance. + Erreur de statut {pro} + Impossible de se connecter au réseau pour vérifier votre statut {pro}. Les informations affichées sur cette page peuvent être inexactes jusqu\'à ce que la connexion soit rétablie.\n\n Veuillez vérifier votre connexion réseau et réessayer. + Chargement du statut {pro} + Vos informations {pro} sont en cours de chargement. Certaines actions sur cette page peuvent être indisponibles jusqu\'à la fin du chargement. + Chargement du statut {pro} + Impossible de se connecter au réseau pour vérifier votre statut {pro}. Vous ne pouvez pas continuer tant que la connexion n\'est pas rétablie.\n\n Veuillez vérifier votre connexion réseau et réessayer. + Impossible de se connecter au réseau pour vérifier votre statut {pro}. Vous ne pouvez pas passer à {pro} tant que la connexion n\'est pas rétablie.\n\n Veuillez vérifier votre connexion réseau et réessayer. + Impossible de se connecter au réseau pour actualiser votre statut {pro}. Certaines actions sur cette page seront désactivées jusqu\'à ce que la connexion soit rétablie.\n\n Veuillez vérifier votre connexion réseau et réessayer. + Impossible de se connecter au réseau pour charger votre accès {pro} actuel. Le renouvellement de {pro} via {app_name} sera désactivé jusqu\'à ce que la connexion soit rétablie.\n\n Veuillez vérifier votre connexion réseau et réessayer. + Besoin d\'aide avec {pro} ? Envoyez une demande au support. En mettant à jour, vous acceptez les Conditions d\'utilisation {icon} et la Politique de confidentialité {icon} de {app_pro} Épingles illimitées Organisez toutes vos discussions avec un nombre illimité de conversations épinglées. - Vous êtes actuellement sur le forfait {current_plan}. Voulez-vous vraiment passer au forfait {selected_plan}?\n\nEn mettant à jour, votre forfait sera automatiquement renouvelé le {date} pour {selected_plan} supplémentaire de l\'accès {pro}. - Votre forfait expirera le {date}.\n\nEn le mettant à jour, votre forfait sera automatiquement renouvelé le {date} pour {selected_plan} supplémentaires d’accès Pro. - Vous voulez tirer le meilleur parti de {app_name} ? Passez à {app_pro} pour une expérience de messagerie améliorée. - {platform_account} traite votre demande de remboursement + Votre option de facturation actuelle vous donne droit à {current_plan_length} d\'accès {pro}. Voulez-vous vraiment passer à l\'option de facturation {selected_plan_length_singular}?\n\nEn mettant à jour, votre accès {pro} sera automatiquement renouvelé le {date} pour {selected_plan_length} supplémentaire d\'accès {pro}. + Votre accès {pro} expirera le {date}.\n\nEn le mettant à jour, votre accès {pro} sera automatiquement renouvelé le {date} pour {selected_plan_length} supplémentaires d’accès {pro}. + Passez à {app_pro} Bêta pour accéder à de nombreux avantages et fonctionnalités exclusifs. + Passez à {pro} depuis les paramètres de {app_pro} sur un appareil lié avec {app_name} installé via {platform_store} ou {platform_store_other}. + Actuellement, les accès {pro} ne peuvent être achetés que via les boutiques {platform_store} et {platform_store_other}. Étant donné que vous avez installé {app_name} à l\'aide de la version {build_variant}, vous ne pouvez pas effectuer de mise à niveau vers {pro} ici.\n\nLes développeurs de {app_name} travaillent activement sur des options de paiement alternatives pour permettre aux utilisateurs d\'acheter des abonnements {pro} en dehors des boutiques {platform_store} et {platform_store_other}. Feuille de route {pro}{icon} + Pour l\'instant, il y a un seul moyen de mettre à niveau : + Pour l\'instant, il y a deux moyens de mettre à niveau : + Vous êtes passé à {app_pro} !\nMerci de soutenir {network_name}. + Mise à niveau vers {pro} + En mettant à niveau, vous acceptez les Conditions d\'utilisation {icon} et la Politique de confidentialité {icon} de {app_pro} + {platform} traite votre demande de remboursement Profil Définir une photo de profil Échec de suppression de la photo de profil. @@ -999,6 +1124,11 @@ Veuillez choisir un fichier plus petit. Échec de mise à jour du profil. Promouvoir + Les administrateurs pourront voir les 14 derniers jours d\'historique des messages et ne pourront pas être rétrogradés ou supprimés du groupe. + + Promouvoir un membre + Promouvoir des membres + Échec de la promotion Échec des promotions @@ -1047,19 +1177,43 @@ Voici votre mot de passe de récupération. Si vous l\'envoyez à quelqu\'un, il aura un accès complet à votre compte. Recréer le groupe Rétablir - Comme vous vous êtes initialement inscrit à {app_pro} via un autre {platform_account}, vous devez utiliser ce {platform_account} pour mettre à jour votre abonnement. + Comme vous vous êtes initialement inscrit à {app_pro} via une autre {platform_account}, vous devez utiliser cet autre {platform_account} pour mettre à jour votre abonnement {pro}. + Deux façons de demander un remboursement : Réduis la longueur du message de {count} %1$d caractères restants %1$d caractères restants + Me le rappeler plus tard Supprimer + + Retirer un membre + Supprimer les membres + Échec de supprimer le mot de passe Supprimez votre mot de passe actuel pour {app_name}. Les données stockées localement seront à nouveau chiffrées à l\'aide d\'une clé générée aléatoirement, stockée sur votre appareil. Renouveler + Renouvellement de {pro} Répondre Demander un remboursement + Demandez un remboursement sur le site Web de {platform}, en utilisant le compte {platform_account} avec lequel vous vous êtes inscrit à {pro}. Renvoyer + + Renvoyer l\'invitation + Renvoyer les invitations + + + Renvoyer l\'invitation administrateur + Renvoyer les invitations administrateurs + + + Renvoi de l\'invitation + Renvoi des invitations + + + Renvoie de l\'invitation administrateur + Renvoie des invitations administrateurs + Chargement des pays… Redémarrer Resynchroniser @@ -1159,6 +1313,10 @@ Indisponible Annuler Inconnu + Processeur non pris en charge + Mise à jour + Reconduire l\'accès {pro} + Deux façons de mettre à jour votre accès {pro} : Mises à jour de l’application Mettre à jour les informations de la communauté Le nom et la description de la communauté sont visibles par tous les membres @@ -1173,8 +1331,6 @@ Veuillez entrer une description de groupe plus courte Une nouvelle version de {app_name} est disponible, appuyez pour lancer la mise à jour Une nouvelle version ({version}) de {app_name} est disponible. - Mettre à jour le forfait - Deux façons de mettre à jour votre abonnement : Mettre à jour les informations du profil Votre nom d\'affichage et votre photo de profil sont visibles dans toutes les conversations. Accéder aux notes de mise à jour @@ -1183,6 +1339,8 @@ Dernière mise à jour il y a {relative_time} Mises à jour Mise à jour... + Mettre à niveau + Mettre à niveau {app_name} Mettre à niveau à Téléversement Copier l\'adresse URL @@ -1191,8 +1349,9 @@ Êtes-vous sûr de vouloir ouvrir cette adresse URL dans votre navigateur web ?\n\n{url} Les liens s\'ouvriront dans votre navigateur. Utiliser le mode rapide - Via le site Web {platform_store} - Modifiez votre abonnement en utilisant le compte {platform_account} avec lequel vous vous êtes inscrit, via le site web de {platform_store}. + Modifiez votre abonnement en utilisant le compte {platform_account} avec lequel vous vous êtes inscrit, via le site web de {platform}. + Via le site Web {platform} + Modifiez votre accès {pro} en utilisant le compte {platform_account} avec lequel vous vous êtes inscrit, via le site web de {platform_store}. Vidéo Impossible de lire la vidéo. Afficher @@ -1201,9 +1360,11 @@ Cela peut prendre quelques minutes. Un moment, s’il vous plaît... Attention + La prise en charge d’iOS 15 est terminée. Mettez à jour vers iOS 16 ou une version ultérieure pour continuer à recevoir les mises à jour de l’application. Fenêtre Oui Vous + Votre processeur ne prend pas en charge les instructions SSE 4.2, qui sont requises par {app_name} sur les systèmes d\'exploitation Linux x64 pour traiter les images. Veuillez mettre à niveau vers un processeur compatible ou utiliser un autre système d\'exploitation. Votre mot de passe de récupération Niveau de Zoom Ajuster la taille du texte et des éléments visuels. diff --git a/app/src/main/res/values-b+hi+IN/strings.xml b/app/src/main/res/values-b+hi+IN/strings.xml index 66e3bfc015..d6cefc4556 100644 --- a/app/src/main/res/values-b+hi+IN/strings.xml +++ b/app/src/main/res/values-b+hi+IN/strings.xml @@ -15,7 +15,6 @@ यह आपका Account ID है। अन्य उपयोगकर्ता आपके साथ बातचीत शुरू करने के लिए इसे स्कैन कर सकते हैं। वास्तविक आकार जोड़ें - एडमिन जोड़ें उस उपयोगकर्ता का Account ID दर्ज करें जिसे आप एडमिन बना रहे हैं।\n\nएक से अधिक उपयोगकर्ताओं को जोड़ने के लिए, प्रत्येक Account ID को कॉमा से अलग करके दर्ज करें। एक बार में अधिकतम 20 Account ID दर्ज किए जा सकते हैं। एडमिन हटाए नहीं जा सकते। {name} और {count} अन्य को Admin बनाया गया। @@ -822,13 +821,9 @@ सक्रिय किया गया आपके पास पहले से ही है आगे बढ़ें और अपनी डिस्प्ले तस्वीर के लिए GIF और एनिमेटेड WebP इमेज अपलोड करें! - एनीमेटेड डिस्प्ले तस्वीरें प्राप्त करें और {app_pro} के साथ प्रीमियम सुविधाओं का अनलॉक करें एनिमेटेड डिस्प्ले तस्वीर उपयोगकर्ता GIF अपलोड कर सकते हैं GIF अपलोड करें - लंबे संदेश भेजना चाहते हैं? अधिक टेक्स्ट भेजें और {app_pro} के साथ प्रीमियम सुविधाओं का अनलॉक करें - अधिक पिन करना चाहते हैं? अपनी चैट व्यवस्थित करें और {app_pro} के साथ प्रीमियम सुविधाओं का अनलॉक करें - 5 से अधिक पिन करना चाहते हैं? अपनी चैट व्यवस्थित करें और {app_pro} के साथ प्रीमियम सुविधाओं का अनलॉक करें GIF और WebP डिस्प्ले तस्वीरें अपलोड करें 300 सदस्यों तक बड़े समूह चैट साथ में कई और विशेष सुविधाएं @@ -840,7 +835,6 @@ बढ़ाई गई संदेश लंबाई इस संदेश में निम्नलिखित {app_pro} विशेषताएँ उपयोग की गईं हैं: इसके साथ और भेजें - क्या आप {app_name} से अधिक प्राप्त करना चाहते हैं? एक अधिक शक्तिशाली संदेश अनुभव के लिए {app_pro} में अपग्रेड करें। प्रोफ़ाइल प्रदर्शन चित्र डिस्प्ले तस्वीर हटाने में विफल। diff --git a/app/src/main/res/values-b+hu+HU/strings.xml b/app/src/main/res/values-b+hu+HU/strings.xml index 0ec82a806a..ae6f1f5f2a 100644 --- a/app/src/main/res/values-b+hu+HU/strings.xml +++ b/app/src/main/res/values-b+hu+HU/strings.xml @@ -15,7 +15,6 @@ Ez a te Felhasználó ID-d. Más felhasználók beszkennelhetik, hogy egy beszélgetést indítsanak el veled. Eredeti méret Hozzáadás - Adminisztrátorok hozzáadása Adja meg a felhasználó fiókazonosítóját, akit adminisztrátorrá kíván kinevezni.\n\nEgyszerre több felhasználó hozzáadásához adja meg az egyes fiókazonosítókat vesszővel elválasztva. Egyszerre legfeljebb 20 fiókazonosító adható meg. Adminokat nem lehet eltávolítani. {name} és {count} másik személy adminisztrátorrá lettek előléptetve. @@ -51,7 +50,7 @@ Névtelen Alkalmazásikon Az alkalmazás ikonjának és nevének módosítása - Az alkalmazás ikonjának és nevének módosításához be kell zárni a Session alkalmazást. Az értesítések továbbra is az alapértelmezett Session ikont és nevet fogják használni. + Az alkalmazás ikonjának és nevének módosításához be kell zárni a {app_name} alkalmazást. Az értesítések továbbra is az alapértelmezett {app_name} ikont és nevet fogják használni. Az alternatív alkalmazásikon és név megjelenik a kezdőképernyőn és az alkalmazásfiókban. A kiválasztott alkalmazás ikonja és neve megjelenik a kezdőképernyőn és az alkalmazásfiókban. Ikon és név @@ -66,6 +65,7 @@ Jegyzetek Részvények Időjárás + Automatikus sötét mód Menüsor elrejtése Nyelv Válaszd ki a nyelvi beállításaidat a {app_name} alkalmazáshoz. A {app_name} újraindul, amikor megváltoztatod a nyelvi beállítást. @@ -183,6 +183,7 @@ Hívások (béta) Hang és videó hívások Hang és videó hívások (béta) + Az IP címed látható a hívópartnered és egy {session_foundation} szerver számára a béta hívások használata közben. Lehetővé teszi a hang- és videohívásokat más felhasználókkal. Felhívtad őt: {name} Elmulasztottad {name} hívását, mert a hang- és videó hívások funkció nincs engedélyezve az adatvédelmi beállításokban. @@ -273,11 +274,15 @@ Beszélgetés törölve A {conversation_name} beszélgetésben nincsenek további üzenetek. Enter billentyű + SHIFT + ENTER elküldi az üzenetet, ENTER új sort kezd. + ENTER elküldi az üzenetet, SHIFT + ENTER új sort kezd. Csoportok Üzenetek rövidítése Közösségek rövidítése + A 2000+ üzenetet tartalmazó közösségekben automatikusan törli a 6 hónapnál régebbi üzeneteket. Új beszélgetés Még nincsenek beszélgetéseid + Küldés az enter billentyűvel Az Enter billentyű lenyomása elküldi az üzenetet új sor kezdése helyett. Összes médiafájl Helyesírás ellenőrzése @@ -426,6 +431,10 @@ Te és {name} ezzel reagáltatok: {emoji_name} Reagált az üzenetedre {emoji} Engedélyezés + Tetszik a {app_name} alkalmazás? + Még van min javítani {emoji} + Fantasztikus {emoji} + Már egy ideje használod a {app_name}-t, hogy tetszik? Nagyon örülnénk, ha megosztanád velünk a véleményedet. Ellenőrizd az internetkapcsolatot, majd próbáld újra. Hiba másolása és kilépés Adatbázishiba @@ -435,12 +444,15 @@ Hibák Fájl Fájlok + Rendszerbeállítások használata. Örökre Feladó: Teljes képernyő be-/kikapcsolása GIF Giphy {app_name} a Giphy-hez csatlakozik a keresési eredmények mutatásához. A GIF-ek küldésekor nem lesz teljes metaadat védelmed. + Visszajelzés küldése? + Sajnáljuk, hogy a {app_name} használata nem volt tökéletes. Hálásak lennénk, ha egy rövid kérdőívben megosztanád velünk a véleményed A csoportokban maximum 100 tag lehet Csoport létrehozása Válassz legalább 1 csoporttagot. @@ -531,13 +543,16 @@ Kapcsolat jelöltek kezelése GYIK Segíts lefordítani {app_name}-t + Hiba jelentése Ossza meg velünk a problémája részleteit. Exportálja a naplóit, majd töltse fel a fájlt a(z) {app_name} Help Desk-en keresztül. Naplók exportálása Mentsd el a hibanaplót, majd a töltsd fel a(z) {app_name} ügyfélszolgálaton keresztül. Mentés az asztalra + Mentse ezt a fájlt, majd ossza meg a {app_name} fejlesztőivel. Támogatás Örülnénk a visszajelzésednek Elrejtés + A rendszer menüsáv láthatóságának be/kikapcsolása. Biztosan el akarja rejteni a Jegyzet magamnak jegyzetet a beszélgetési listából? Többi elrejtése Kép @@ -755,19 +770,27 @@ Nem sikerült felismernünk ezt az ONS-t. Ellenőrizd, és próbáld újra. Az ONS keresése nem sikerült. Próbáld újra később. Megnyitás + Kérdőív megnyitása Egyéb Jelszó megváltoztatása + A jelszó megváltoztatásához szükséges a {app_name} feloldása. + A jelszó meg lett változtatva. Tartsd biztonságos helyen! Jelszó megerősítése + Jelszó létrehozása Az aktuális jelszavad helytelen. Jelszó megadása Add meg a jelenlegi jelszavad Add meg az új jelszavad A jelszó csak betűket, számokat, és szimbólumokat tartalmazhat + A jelszónak minimum {min} és maximum {max} karakter hosszúságúnak kell lennie A megadott jelszavak nem egyeznek Jelszó frissítése sikertelen Hibás jelszó Jelszó eltávolítása + A jelszavad el lett távolítva. Jelszó beállítása + A jelszavad be lett állítva. Tartsd biztonságos helyen! + Kérjen jelszót a {app_name} feloldásához. Beillesztés Engedélyváltozás {app_name} alkalmazásnak zene és hang-hozzáférésre van szüksége a fájlok és zenék küldéséhez, de ez nem lett megadva. Kérlek, lépj tovább az alkalmazás beállításokhoz, válaszd az \"Engedélyek\" lehetőséget, majd engedélyezd a \"Zene és hang\" hozzáférést. @@ -780,6 +803,7 @@ Engedélyezze a kamerához való hozzáférést videohívásokhoz. A {app_name} képernyőzár funkciója Face ID-t használ. Rendszertálcán tartás + {app_name} az ablak bezárása után tovább fut a háttérben. {app_name} alkalmazásnak fotótár-hozzáférésre van szüksége a folytatáshoz. A hozzáférést az iOS beállításokban engedélyezheted. A hívások lehetővé tételéhez szükséges a helyi hálózathoz való hozzáférés. Kapcsolja be a „Helyi hálózat” engedélyt a Beállításokban a folytatáshoz. A(z) {app_name} alkalmazásnak hozzáférésre van szüksége a helyi hálózathoz a hang- és videohívások indításához. @@ -807,10 +831,18 @@ Kitűzés eltávolítása Beszélgetés kitűzésének eltávolítása Előnézet - Szeretne hosszabb üzeneteket küldeni? Küldjön több szöveget és oldja fel a prémium funkciókat a {app_pro} szolgáltatással + aktiválva + Tölts fel GIF-eket és animált WebP képeket a profilképedhez! + felhasználók feltölthetnek GIF-eket + GIF és WebP formátumú profilképek feltöltése Nagyobb csoportos beszélgetések akár 300 taggal Plusz még több exkluzív funkció Legfeljebb 10 000 karakteres üzenetek + Tűzz ki korlátlan számú beszélgetést + + %1$s hosszabb üzenet elküldve + %1$s hosszabb üzenet elküldve + Küldjön többet ezzel: Profil Profilkép @@ -835,6 +867,9 @@ Az ismerőseid üzenetet küldhetnek neked a QR-kódod beolvasásával. Kilépés a {app_name}-ből Kilépés + Értékeli a {app_name} alkalmazást? + Alkalmazás értékelése + Örülünk, hogy élvezed a {app_name} használatát! Ha van egy perced, értékelj minket a(z) {storevariant}-on – ezzel másoknak is segítesz felfedezni a privát és biztonságos üzenetváltást! Elolvasva Olvasási visszaigazolás Olvasottsági visszajelzések megjelenítése az összes elküldött és fogadott üzenethez. @@ -845,6 +880,7 @@ Ajánlott Mentsd el a visszaállítási jelszavad, hogy biztosan ne veszítsd el a hozzáférést a fiókodhoz. Visszaállítási jelszó elmentése + A visszaállítási jelszavaddal új eszközökön is betöltheted a fiókodat.\n\nA fiókod nem állítható vissza a visszaállítási jelszó nélkül. Ügyelj rá, hogy biztonságos helyen tárold — és ne oszd meg senkivel. Add meg a visszaálltási jelszavad Hiba történt a helyreállítási jelszó betöltése közben.\n\nExportálja a naplófájlokat, majd töltse fel azokat a(z) {app_name} segítségével az ügyfélszolgálatnak a probléma megoldása érdekében. Ellenőrizd a visszaállítási jelszavad és próbáld újra. @@ -854,6 +890,7 @@ A fiók betöltéséhez add meg a visszaállítási jelszavadat. Visszaállítási jelszó végleges elrejtése A visszaállítási jelszó nélkül nem tudod betölteni a felhasználódat új eszközökön. \n\nErősen ajánljuk a visszaálltási jelszó biztonságos helyen történő mentését. + Biztos, hogy véglegesen el akarod rejteni a visszaállítási jelszavad ezen az eszközön?\n\nEzt nem lehet visszacsinálni. Visszaállítási jelszó elrejtése A visszaállítási jelszó végleges elrejtése ezen az eszközön. Írd be a visszaállítási jelszavad a fiókod betöltéséhez. Ha nem mentetted el, az alkalmazás beállításai között találhatod meg. diff --git a/app/src/main/res/values-b+id+ID/strings.xml b/app/src/main/res/values-b+id+ID/strings.xml index bf60f43d6d..089192d1e4 100644 --- a/app/src/main/res/values-b+id+ID/strings.xml +++ b/app/src/main/res/values-b+id+ID/strings.xml @@ -15,7 +15,6 @@ Ini adalah ID akun anda. Pengguna lain bisa memindainya untuk memulai percakapan dengan anda. Ukuran Sebenarnya Tambahkan - Tambah Admin Admin tidak dapat dihapus. {name} dan {count} lainnya dipromosikan menjadi Admin. Promosikan Admin diff --git a/app/src/main/res/values-b+it+IT/strings.xml b/app/src/main/res/values-b+it+IT/strings.xml index c9633b0a16..ca3b6e7119 100644 --- a/app/src/main/res/values-b+it+IT/strings.xml +++ b/app/src/main/res/values-b+it+IT/strings.xml @@ -15,7 +15,6 @@ Questo è il tuo ID utente. Altri utenti possono scansionarlo per iniziare una conversazione con te. Dimensione attuale Aggiungi - Aggiungi amministratori Inserisci l\'Account ID dell\'utente che vuoi promuovere ad amministratore.\n\nPer aggiungere più utenti, inserisci ogni Account ID separato da una virgola. È possibile specificare fino a 20 Account ID alla volta. Gli amministratori non possono essere rimossi. {name} e altri {count} sono ora amministratori. @@ -822,13 +821,9 @@ Attivato Hai già attivato Carica GIF e immagini WebP animate per la tua immagine del profilo! - Ottieni immagini del profilo animate e sblocca funzionalità premium con {app_pro} Immagine del profilo animata gli utenti possono caricare GIF Carica GIF con - Vuoi inviare messaggi più lunghi? Invia più testo e sblocca funzionalità premium con {app_pro} - Vuoi più chat bloccate? Organizza le tue chat e sblocca le funzionalità premium con {app_pro} - Vuoi più di 5 chat bloccate? Organizza le tue chat e sblocca le funzionalità premium con {app_pro} Carica immagini profilo in formato GIF e WebP Chat di gruppo maggiori fino a 300 membri E tante altre funzionalità esclusive @@ -840,7 +835,6 @@ Lunghezza messaggio aumentata Questo messaggio ha utilizzato le seguenti funzionalità di {app_pro}: Invia di più con - Vuoi ottenere di più da {app_name}? Passa a {app_pro} per un\'esperienza di messaggistica più potente. Profilo Mostra immagine Impossibile rimuovere l\'immagine del profilo. diff --git a/app/src/main/res/values-b+ja+JP/strings.xml b/app/src/main/res/values-b+ja+JP/strings.xml index 6a2aac7f24..81e830e3b0 100644 --- a/app/src/main/res/values-b+ja+JP/strings.xml +++ b/app/src/main/res/values-b+ja+JP/strings.xml @@ -15,7 +15,6 @@ これはあなたのアカウントIDです。他のユーザーはこれをスキャンしてあなたと会話を始めることができます。 実サイズ 追加 - 管理者を追加する 管理者に昇格させるユーザーのAccount IDを入力してください。\n\n複数のユーザーを追加するには、各Account IDをカンマで区切って入力してください。一度に最大20件まで指定できます。 アドミンを削除することはできません {name}{count}人 がアドミンに昇格しました @@ -800,13 +799,9 @@ アクティベート済み すでにご利用中です ディスプレイ画像としてGIFやアニメーションWebP画像をアップロードできます! - アニメーションディスプレイ画像を取得し、{app_pro}でプレミアム機能を解除しましょう アニメーション表示画像 ユーザーはGIFをアップロードできます GIFをアップロード(PRO) - 長文を送りたいですか?{app_pro}でより多くのテキストを送り、プレミアム機能を解除しましょう。 - さらにピン留めしますか?{app_pro}でチャットを整理して、プレミアム機能を解除しましょう - 5件以上ピン留めしたいですか?{app_pro}でチャットを整理して、プレミアム機能を解除しましょう GIFとWebPのディスプレイ画像をアップロード 最大300人の大型グループチャット さらに多数の限定機能 @@ -818,7 +813,6 @@ メッセージの文字数増加 このメッセージには以下の {app_pro} 機能が使用されています: さらに送信: - {app_name}をもっと活用したいですか?より強力なメッセージ体験のために{app_pro}へアップグレードしましょう。 プロフィール ディスプレイの画像 表示画像の削除に失敗しました。 diff --git a/app/src/main/res/values-b+ko+KR/strings.xml b/app/src/main/res/values-b+ko+KR/strings.xml index 320dcd53be..21d4eca06a 100644 --- a/app/src/main/res/values-b+ko+KR/strings.xml +++ b/app/src/main/res/values-b+ko+KR/strings.xml @@ -15,7 +15,6 @@ 이는 당신의 계정 ID입니다. 다른 사용자가 이를 스캔하여 당신과 대화를 시작할 수 있습니다. 실제 크기 추가 - 관리자 추가 관리자로 승격할 유저의 계정 ID를 입력하세요.\n\n한번에 여러 유저를 추가하려면 각 계정 ID를 쉼표로 구분하여 최대 20명까지 추가할 수 있습니다. 관리자는 추방될 수 없습니다. {name}님{count}명이 관리자(Admin)로 승격되었습니다. @@ -65,6 +64,7 @@ 노트 주식 날씨 + 자동 다크 모드 메뉴 바 숨기기 언어 {app_name}의 언어 설정을 선택하십시오. 언어 설정을 변경하면 {app_name}이 재시작됩니다. @@ -182,6 +182,7 @@ 통화 (베타) 음성 및 영상 통화 음성 및 영상 통화 (Beta) + 베타 버전 통화를 사용하면 상대방과 {session_foundation} 서버에 IP가 노출됩니다. 다른 사용자와 음성 및 영상 통화를 할 수 있습니다. {name}님에게 전화함 {name}님으로부터 받은 전화를 놓쳤습니다. 개인 정보 설정에서 음성 및 화상 통화를 활성화하지 않았기 때문입니다. @@ -271,11 +272,15 @@ 대화를 삭제함 {conversation_name}에는 메시지가 없습니다. 키 입력 + SHIFT + ENTER 메시지 전송, ENTER 새 줄 시작. + ENTER 키는 메시지를 전송하며, SHIFT + ENTER 키는 줄바꿈을 합니다. 그룹 대화 줄이기 커뮤니티 정돈 + 2000개 이상의 메시지가 있는 커뮤니티에서 6개월 이상 지난 메시지를 자동 삭제. 새 대화 아직 대화가 없습니다 + ENTER 키로 전송 Enter를 누를 때 줄바꿈 대신 메시지를 전송합니다. 모든 미디어 맞춤법 검사 @@ -425,6 +430,7 @@ 실패 파일 파일들 + 시스템 설정 모드. 영원히 보낸 사람: Toggle Full Screen @@ -518,10 +524,12 @@ 연결 후보 처리 중 자주 묻는 질문 {app_name} 번역 돕기 + 버그 제보 문제를 해결하는 데 도움이 될 수 있도록 몇 가지 세부 정보를 공유해 주세요. 로그를 내보낸 다음 {app_name}의 도움말 센터에 파일을 업로드하세요. 로그 내보내기 로그를 내보내고, {app_name}의 지원 데스크를 통하여 파일을 업로드하세요. 데스크탑에 저장 + 이 파일을 저장하고 {app_name} 개발자와 공유합니다. 지원 여러분의 의견을 기다리고 있습니다 숨기기 @@ -598,6 +606,7 @@ 새 메시지만 공유 초대 메시지 + 더보기 이 메시지는 비어 있습니다. 메시지 전송 실패 글자 수 제한을 초과했습니다. @@ -648,6 +657,14 @@ {author}: {emoji} 음성 메시지 메시지 최소화 + + 메시지를 최대 %1$s자 작성할 수 있습니다. %2$d자 남았습니다. + + 메시지 길이 + 이 메시지에 사용된 문자 수가 제한을 초과했습니다. 메시지를 {limit}자 이하로 줄여 주세요. + 메시지 길이 초과 + 메시지 길이를 {limit}글자 이하로 줄여주세요. + 메시지 길이 초과 다음 {name}의 닉네임을 선택하십시오. 일대일 및 그룹 채팅에서 표시됩니다. 닉네임을 입력하세요. @@ -726,17 +743,24 @@ 열기 기타 비밀번호 변경 + {app_name} 잠금 해제 시 사용되는 비밀번호를 변경합니다. + 비밀번호 변경이 완료되었습니다. 안전히 관리하시기 바랍니다. 비밀번호 확인 + 비밀번호 생성 현재 비밀번호가 잘못되었습니다. 비밀번호 입력 현재 비밀번호를 입력해주세요 새 비밀번호를 입력해 주세요 비밀번호는 문자, 숫자 및 간단한 기호만으로 구성되야 합니다 + 비밀번호는 {min} 글자 이상, {max} 글자 이하여야 합니다 비밀번호가 일치하지 않습니다 비밀번호 설정 실패 잘못된 비밀번호 비밀번호 제거 + 비밀번호가 제거되었습니다. 비밀번호 설정 + 비밀번호를 설정했습니다. 잊어버리지 않게 주의하세요. + {app_name}앱을 시작할 때 비밀번호를 요구합니다. 붙여넣기 권한 변경 {app_name}은(는) 파일, 음악 및 오디오를 전송하기 위해 음악 및 오디오 접근이 필요하지만, 접근이 영구적으로 거부되었습니다. 설정 → 권한으로 이동하여 \"음악 및 오디오\"를 켜십시오. @@ -749,6 +773,7 @@ 영상 통화를 위해 카메라 접근을 허용하세요. {app_name}의 화면 잠금 기능은 Face ID를 사용합니다. 트레이 아이콘 유지 + {app_name}은 창을 닫아도 백그라운드에서 동작합니다. {app_name}은 포토 라이브러리 접근권한이 필요합니다. iOS 설정에서 접근 권한을 허용할 수 있습니다. 통화를 위해 “로컬 네트워크” 접근이 필요합니다. 계속하려면 설정에서 “로컬 네트워크” 권한을 켜세요. {app_name}이 음성 및 영상 통화를 하기 위해 로컬 네트워크에 접근해야 합니다. @@ -776,6 +801,9 @@ 고정 해제 대화 고정 취소 미리보기 + + 고정된 대화 %1$s개 + 프로필 프로필 사진 설정 표시 사진을 제거하지 못했습니다. @@ -807,6 +835,7 @@ 권장 계정에 접근 권한을 잃지 않도록 회복 비밀번호를 저장하세요. 회복 비밀번호 저장 + 복구 비밀번호를 사용해 다른 기기에서 계정을 불러올 수 있습니다. \n\n복구 비밀번호가 없으면 계정을 불러올 수 없습니다. 안전한 곳에 보관하고 다른 사람이 볼 수 없게 하세요. 복구 비밀번호 입력 복구 비밀번호를 불러오는 도중 오류가 발생했습니다.\n\n문제를 해결하기 위해 로그를 내보낸 후 {app_name} 고객 지원 센터에 첨부하여 문의 해주세요. 복구 비밀번호를 확인하시고 다시 시도해주세요. @@ -816,13 +845,17 @@ 계정을 로드하려면 복구 비밀번호를 입력하세요. 복구 비밀번호 영구적으로 숨기기 복구 비밀번호가 없으면 새로운 기기에서 계정을 불러올 수 없습니다.\n\n계속하기 전에 안전하고 보안된 곳에 복구 비밀번호를 저장할 것을 강력히 권장합니다. + 정말 이 장치에서 복구 비밀번호를 영구적으로 숨기겠습니까?\n\n이 작업은 되돌릴 수 없습니다. 복구 비밀번호 숨기기 이 기기에서 복구 비밀번호를 영구적으로 숨깁니다. 계정을 로드하려면 복구 비밀번호를 입력하세요. 저장되지 않았다면, 앱 설정에서 찾을 수 있습니다. 이것은 당신의 복구 비밀번호입니다. 다른 사람에게 보내면 그들이 당신의 계정에 완전히 접근할 수 있게 됩니다. 그룹 다시 만들기 다시 실행 - {count}자 입력 가능 + 메시지 길이를 {count}자 줄이세요 + + %1$d글자 남았습니다 + 삭제 비밀번호를 제거하지 못했습니다 답장 diff --git a/app/src/main/res/values-b+nl+NL/strings.xml b/app/src/main/res/values-b+nl+NL/strings.xml index 95b7f921c0..561f39a6a6 100644 --- a/app/src/main/res/values-b+nl+NL/strings.xml +++ b/app/src/main/res/values-b+nl+NL/strings.xml @@ -15,7 +15,6 @@ Dit is uw Account-ID. Andere gebruikers kunnen het scannen om een gesprek met u te beginnen. Werkelijke grootte Toevoegen - Beheerders toevoegen Voer de account-ID in van de gebruiker die u promoot als admin.\n\nOm meerdere gebruikers toe te voegen, voer elk account-ID in, gescheiden door een komma. Tot 20 Account ID\'s kunnen tegelijkertijd worden opgegeven. Admins kunnen niet worden verwijderd. {name} en {count} anderen zijn gepromoveerd tot Admin. @@ -198,7 +197,6 @@ {app_name} heeft toegang tot de camera nodig om foto\'s en video\'s te maken of QR-codes te scannen. {app_name} heeft toegang tot de camera nodig om QR-codes te scannen Annuleren - Abonnement annuleren Wijzigen Wachtwoord wijzigen mislukt Wijzig je wachtwoord voor {app_name}. Lokaal opgeslagen gegevens worden opnieuw versleuteld met je nieuwe wachtwoord. @@ -306,7 +304,6 @@ Aanmaken Oproep starten Huidig wachtwoord - Huidig abonnement Knippen Donkere modus Weet u zeker dat u alle berichten, bijlagen en accountgegevens van dit apparaat wilt verwijderen en een nieuw account wilt aanmaken? @@ -783,7 +780,6 @@ Oké Aan Op je {device_type} apparaat - Open dit {app_name} account op een {device_type} apparaat waarop je bent aangemeld met het {platform_account} waarmee je je oorspronkelijk hebt geregistreerd. Wijzig vervolgens je abonnement via de instellingen van {app_pro}. Account aanmaken Account aangemaakt Ik heb een account @@ -808,7 +804,7 @@ We konden deze ONS niet herkennen. Controleer deze alsjeblieft en probeer het opnieuw. We konden niet zoeken naar deze ONS. Probeer het later opnieuw. Openen - Open de {platform_store} website + Open de {platform} website Enquête openen Overige Wachtwoord @@ -887,10 +883,8 @@ Voorbeeldmelding Geactiveerd Alles is geregeld! - Je {app_pro} abonnement is bijgewerkt! Je wordt gefactureerd wanneer je huidige {pro} abonnement automatisch wordt verlengd op {date}. Je hebt al Upload nu GIF\'s en geanimeerde WebP-afbeeldingen voor je profielfoto! - Krijg geanimeerde profielfoto\'s en ontgrendel premiumfuncties met {app_pro} Geanimeerde profielfoto gebruikers kunnen GIF\'s uploaden Geanimeerde profielfoto\'s @@ -905,14 +899,8 @@ {price} Jaarlijks gefactureerd {price} Maandelijks gefactureerd {price} per kwartaal gefactureerd - Wil je langere berichten versturen? Verstuur meer tekst en ontgrendel premiumfuncties met {app_pro} - Wil je meer vastzetten? Organiseer je chats en ontgrendel premiumfuncties met {app_pro} - Wil je meer dan 5 vastgezette gesprekken? Organiseer je chats en ontgrendel premiumfuncties met {app_pro} - Je huidige abonnement is al met {percent}% korting ten opzichte van de volledige {app_pro} prijs. Verlopen - Helaas is je {pro} abonnement verlopen. Verleng om toegang te blijven houden tot de exclusieve voordelen en functies van {app_pro}. Verloopt binnenkort - Je {pro} abonnement verloopt over {time}. Werk je abonnement bij om toegang te blijven houden tot de exclusieve voordelen en functies van {app_pro}. {pro} FAQ Vind antwoorden op veelgestelde vragen in de {app_pro} FAQ. Upload GIF- en WebP-profielfoto\'s @@ -922,7 +910,6 @@ Onbeperkt gesprekken vastzetten Groep geactiveerd Deze groep heeft een grotere capaciteit! Er kunnen nu tot 300 leden deelnemen omdat een groepsbeheerder een - Het aanvragen van een terugbetaling is definitief. Indien goedgekeurd, wordt je {pro} abonnement onmiddellijk geannuleerd en verlies je de toegang tot alle {pro} functies. Verhoogde bijlagegrootte Verlengde berichtlengte Grotere groepen @@ -931,47 +918,26 @@ Je kunt berichten tot 10.000 tekens verzenden in alle gesprekken. Dit bericht maakte gebruik van de volgende {app_pro}-functies: {percent}% korting - Je {app_pro} abonnement is actief!\n\nJe abonnement wordt automatisch verlengd voor een nieuw {current_plan} op {date}. Wijzigingen aan je abonnement gaan in wanneer {pro} de volgende keer wordt verlengd. - Je {app_pro} abonnement is actief!\n\nJe abonnement wordt automatisch verlengd met een {current_plan} op {date}. - Je {app_pro} abonnement verloopt op {date}.\n\nWerk je abonnement nu bij om ononderbroken toegang te behouden tot exclusieve Pro functies. - Je {app_pro} abonnement verloopt op {date}. - {pro} abonnement niet gevonden - Er is geen actief abonnement gevonden voor je account. Als je denkt dat dit een vergissing is, neem dan contact op met de ondersteuning van {app_name} voor hulp. Omdat je je oorspronkelijk hebt aangemeld voor {app_pro} via de {platform_store} winkel, moet je hetzelfde {platform_account} gebruiken om een terugbetaling aan te vragen. - Omdat je je oorspronkelijk hebt aangemeld voor {app_pro} via de {platform_store} winkel, wordt je restitutieverzoek afgehandeld door {app_name} Support.\n\nVraag een restitutie aan door op de knop hieronder te drukken en het restitutieformulier in te vullen.\n\nHoewel {app_name} Support ernaar streeft om restitutieverzoeken binnen 24-72 uur te verwerken, kan het tijdens drukte langer duren - {pro} abonnement herstellen - {pro} abonnement verlengen - Momenteel kunnen {pro} abonnementen alleen worden gekocht en verlengd via de {platform_store}- of {platform_store} winkels. Omdat je {app_name} Desktop gebruikt, kun je je abonnement hier niet verlengen.\n\nDe ontwikkelaars van {app_pro} werken hard aan alternatieve betaalmogelijkheden, zodat gebruikers {pro} abonnementen buiten de {platform_store}- en {platform_store} winkels kunnen aanschaffen. {pro} Routekaart{icon} - Verleng je abonnement in de {app_pro} instellingen op een gekoppeld apparaat met {app_name} geïnstalleerd via de {platform_store} of {platform_store} winkel. - Verleng je abonnement op de {platform_store} website met het {platform_account} waarmee je je voor {pro} hebt aangemeld. - Verleng je {app_pro} abonnement om opnieuw gebruik te maken van krachtige {app_pro} functies. - Je {app_pro} abonnement is verlengd! Bedankt voor je steun aan de {network_name}. - {pro} abonnement hersteld - Een geldig abonnement voor {app_pro} is gedetecteerd en je {pro} status is hersteld! - Omdat je je oorspronkelijk hebt aangemeld voor {app_pro} via de {platform_store} winkel, moet je je {platform_account} gebruiken om je abonnement bij te werken. + Omdat je je oorspronkelijk hebt aangemeld voor {app_pro} via de {platform_store} winkel, wordt je restitutieverzoek afgehandeld door {app_name} Support.\n\nVraag een restitutie aan door op de knop hieronder te drukken en het restitutieformulier in te vullen.\n\nHoewel {app_name} Support ernaar streeft om restitutieverzoeken binnen 24-72 uur te verwerken, kan het tijdens drukte langer duren. 1 maand - {monthly_price} / maand 3 maanden - {monthly_price} / maand 12 maanden - {monthly_price} / maand Het spijt ons dat je vertrekt. Dit moet je weten voordat je een terugbetaling aanvraagt. - {platform_account} verwerkt nu je terugbetalingsverzoek. Dit duurt meestal 24-48 uur. Afhankelijk van hun beslissing kan je {pro} status wijzigen in {app_name}. + {platform} verwerkt nu je terugbetalingsverzoek. Dit duurt meestal 24-48 uur. Afhankelijk van hun beslissing kan je {pro} status wijzigen in {app_name}. Je restitutieverzoek wordt afgehandeld door {app_name} Support.\n\nVraag een restitutie aan door op de knop hieronder te drukken en het restitutieformulier in te vullen.\n\nHoewel {app_name} Support ernaar streeft om restitutieverzoeken binnen 24-72 uur te verwerken, kan het tijdens drukte langer duren. - Je terugbetalingsverzoek wordt uitsluitend afgehandeld door {platform_account} via de website van {platform_account}.\n\nVanwege het restitutiebeleid van {platform_account} hebben ontwikkelaars van {app_name} geen invloed op de uitkomst van terugbetalingsverzoeken. Dit geldt zowel voor de goedkeuring of afwijzing van het verzoek als voor het wel of niet toekennen van een volledige of gedeeltelijke terugbetaling. - Neem contact op met {platform_account} voor verdere updates over je restitutieverzoek. Vanwege het restitutiebeleid van {platform_account} hebben de ontwikkelaars van {app_name} geen invloed op de uitkomst van restitutieverzoeken.\n\n{platform_store} Terugbetalingsondersteuning + Je terugbetalingsverzoek wordt uitsluitend afgehandeld door {platform} via de website van {platform}.\n\nVanwege het restitutiebeleid van {platform} hebben ontwikkelaars van {app_name} geen invloed op de uitkomst van terugbetalingsverzoeken. Dit geldt zowel voor de goedkeuring of afwijzing van het verzoek als voor het wel of niet toekennen van een volledige of gedeeltelijke terugbetaling. + Neem contact op met {platform} voor verdere updates over je restitutieverzoek. Vanwege het restitutiebeleid van {platform} hebben de ontwikkelaars van {app_name} geen invloed op de uitkomst van restitutieverzoeken.\n\n{platform} Terugbetalingsondersteuning Terugbetalen {pro} - Terugbetalingen voor {app_pro} abonnementen worden uitsluitend afgehandeld door {platform_account} via de {platform_store} Store.\n\nVanwege het restitutiebeleid van {platform_account} hebben ontwikkelaars van {app_name} geen invloed op de uitkomst van terugbetalingsverzoeken. Dit geldt zowel voor de goedkeuring of afwijzing van het verzoek als voor het wel of niet toekennen van een volledige of gedeeltelijke terugbetaling. Terugbetaling aangevraagd Verstuur meer met {pro} instellingen Je {pro} statistieken {pro} statistieken weerspiegelen het gebruik op dit apparaat en kunnen anders weergegeven worden op gekoppelde apparaten - Hulp nodig met je {pro} abonnement? Dien een verzoek in bij het ondersteuningsteam. Door bij te werken ga je akkoord met de {app_pro} Gebruiksvoorwaarden {icon} en het Privacybeleid {icon} Onbeperkte Pins Organiseer al je chats met onbeperkt vastgezette gesprekken. - Je zit momenteel op het {current_plan} abonnement. Weet je zeker dat je wilt overschakelen naar het {selected_plan} abonnement?\n\nAls je dit bijwerkt, wordt je abonnement op {date} automatisch verlengd met {selected_plan} {pro} toegang. - Je abonnement verloopt op {date}.\n\nDoor bij te werken wordt je abonnement automatisch verlengd op {date} voor een extra {selected_plan} aan Pro-toegang. - Wil je meer uit {app_name} halen? Upgrade naar {app_pro} voor een krachtigere berichtbeleving. - {platform_account} verwerkt je restitutieverzoek + {platform} verwerkt je restitutieverzoek Profiel Toon afbeelding Verwijderen profielfoto mislukt. @@ -1027,7 +993,6 @@ Dit is uw herstelwachtwoord. Als u het naar iemand stuurt hebben ze volledige toegang tot uw account. Groep opnieuw aanmaken Opnieuw doen - Omdat je je oorspronkelijk hebt aangemeld voor {app_pro} via een ander {platform_account}, moet je datzelfde {platform_account} gebruiken om je abonnement bij te werken. Verkort berichtlengte met {count} %1$d teken resterend @@ -1149,8 +1114,6 @@ Vul alstublieft een kortere groepsnaam in Er is een nieuwe versie van {app_name} beschikbaar, tik om bij te werken Versie ({version}) van {app_name} is beschikbaar. - Abonnement bijwerken - Twee manieren om je abonnement bij te werken: Profielinformatie bijwerken Je weergavenaam en profielfoto zijn zichtbaar in alle gesprekken. Ga naar Release Opmerkingen @@ -1167,8 +1130,7 @@ Weet u zeker dat u deze URL in uw browser wilt openen?\n\n{url} Links worden in uw browser geopend. Gebruik Snelle Modus - Via de {platform_store} website - Wijzig je abonnement met het {platform_account} waarmee je je hebt aangemeld, via de {platform_store} website. + Via de {platform} website Video Kan video niet afspelen. Bekijken diff --git a/app/src/main/res/values-b+pl+PL/strings.xml b/app/src/main/res/values-b+pl+PL/strings.xml index f84d6b2b93..168d526e5c 100644 --- a/app/src/main/res/values-b+pl+PL/strings.xml +++ b/app/src/main/res/values-b+pl+PL/strings.xml @@ -15,7 +15,6 @@ To Twój identyfikator konta. Inni użytkownicy mogą go zeskanować, aby rozpocząć z Tobą rozmowę. Rzeczywisty rozmiar Dodaj - Dodaj administratorów Wprowadź identyfikator konta użytkownika, którego chcesz awansować na administratora.\n\nAby dodać wielu użytkowników, wpisz każdy identyfikator konta oddzielone przecinkiem. Można jednocześnie podać maksymalnie 20 identyfikatorów kont. Nie można usuwać administratorów. {name} i {count} innych zostali awansowani na administratów. @@ -200,7 +199,6 @@ Aby robić zdjęcia, nagrywać filmy i skanować kody QR, aplikacja {app_name} potrzebuje dostępu do aparatu Aby skanować kody QR, aplikacja {app_name} wymaga dostępu do aparatu Anulowanie - Anuluj plan Zmień Nie udało się zmienić hasła Zmień hasło dla {app_name}. Dane przechowywane lokalnie zostaną ponownie zaszyfrowane nowym hasłem. @@ -310,7 +308,6 @@ Utwórz Tworzenie połączenia Obecne hasło - Obecny plan Wytnij Tryb ciemny Czy na pewno chcesz usunąć wszystkie wiadomości, załączniki i dane konta z tego urządzenia i utworzyć nowe konto? @@ -925,10 +922,8 @@ Podgląd powiadomień Aktywowano Wszystko gotowe! - Twój plan {app_pro} został zaktualizowany! Opłata zostanie pobrana, kiedy Twój obecny plan {pro} odnowi się automatycznie {date}. Masz już Możesz przesyłać GIF-y i animowane obrazy WebP jako swoje zdjęcie profilowe! - Zyskaj animowane zdjęcia profilowe i odblokuj funkcje premium dzięki {app_pro} Animowany obraz profilowy użytkownicy mogą przesyłać GIF-y Animowane obrazy profilu @@ -949,13 +944,7 @@ Opłata roczna: {price} Opłata miesięczna: {price} Opłata kwartalna: {price} - Chcesz wysyłać dłuższe wiadomości? Wyślij więcej tekstu i odblokuj funkcje premium dzięki {app_pro} - Chcesz przypinać więcej czatów? Zorganizuj konwersacje i odblokuj funkcje premium dzięki {app_pro} - Chcesz przypiąć więcej niż 5 czatów? Zorganizuj konwersacje i odblokuj funkcje premium dzięki {app_pro} - Cena Twojego obecnego planu jest obniżona o {percent}% pełnej ceny {app_pro}. - Niestety, Twój plan {pro} wygasł. Odnów go, by odzyskać dostęp do ekskluzywnych korzyści i funkcji {app_pro}. Niedługo wygaśnie - Twój plan {pro} wygasa za {time}. Zaktualizuj swój plan, aby zachować dostęp do ekskluzywnych korzyści i funkcji {app_pro}. FAQ {pro} Znajdź odpowiedzi na często zadawane pytania w sekcji FAQ {app_pro}. Prześlij obrazy profilowe w formacie GIF i WebP @@ -971,7 +960,6 @@ Ulepszono %1$s grup Ulepszono %1$s grup - Wniosek o zwrot jest ostateczny. Jeżeli zostanie zatwierdzony, Twój plan {pro} zostanie natychmiast anulowany i utracisz dostęp do wszystkich funkcji {pro}. Zwiększony rozmiar załączników Zwiększona długość wiadomości Większe grupy @@ -990,18 +978,6 @@ %1$s przypiętych konwersacji %1$s przypiętych konwersacji - Twój plan {app_pro} jest aktywny!\n\nZostanie on automatycznie odnowiony na kolejny {current_plan}, dnia {date}. Zmiany w Twoim planie wejdą w życie przy następnym odnowieniu subskrypcji {pro}. - Twój plan {app_pro} jest aktywny!\n\nZostanie on automatycznie odnowiony na kolejny {current_plan}, dnia {date}. - Twój plan {app_pro} wygasa {date}.\n\nZaktualizuj swój plan już teraz, by zapewnić sobie nieprzerwany dostęp do ekskluzywnych funkcji Pro. - Twój plan {app_pro} wygasa {date}. - Nie znaleziono planu {pro} - Nie znaleziono aktywnych planów dla Twojego konta. Jeżeli uważasz, że to błąd, prosimy o kontakt z Supportem {app_name}. - Odzyskaj plan {pro} - Odnów plan {pro} - Odnów swój plan {app_pro}, aby znów używać potężnych funkcji {app_pro} Beta. - Twój plan {app_pro} został odnowiony! Dziękujemy za wspieranie {network_name}. - Plan {pro} został odzyskany - Wykryto ważny plan {app_pro} oraz przywrócono Twój status {pro}! 1 miesiąc - {monthly_price} / miesiąc 3 miesiące - {monthly_price} / miesiąc 12 miesięcy - {monthly_price} / miesiąc @@ -1012,11 +988,9 @@ Ustawienia {pro} Twoje Statystyki {pro} Statystyki {pro} pokazują użycie na tym urządzeniu i mogą wyglądać różnie na połączonych urządzeniach - Potrzebujesz pomocy z planem {pro}? Wyślij zgłoszenie zespołowi wsparcia. Dokonując zmian wyrażasz zgodę na Warunki Świadczenia Usług {app_pro} {icon} oraz Politykę Prywatności {icon} Nielimitowane przypięcia Organizuj swoje czaty z nielimitowaną możliwością przypinania konwersacji. - Chcesz więcej z {app_name}? Uaktualnij do {app_pro}, aby uzyskać potężniejsze możliwości wiadomości. Profil Zdjęcie profilowe Nie udało się usunąć zdjęcia profilowego. @@ -1199,8 +1173,6 @@ Wprowadź krótszy opis grupy Dostępna jest nowa wersja aplikacji {app_name}. Stuknij, aby zaktualizować Dostępna jest nowa wersja ({version}) aplikacji {app_name}. - Zaktualizuj plan - Dwa sposoby na aktualizację planu: Zaktualizuj informacje w profilu Twoja nazwa wyświetlana i obraz profilu są widoczne we wszystkich konwersacjach. Przejdź do informacji o wersji diff --git a/app/src/main/res/values-b+pt+PT/strings.xml b/app/src/main/res/values-b+pt+PT/strings.xml index bb75737d14..a4c4f24421 100644 --- a/app/src/main/res/values-b+pt+PT/strings.xml +++ b/app/src/main/res/values-b+pt+PT/strings.xml @@ -15,7 +15,6 @@ Este é o seu ID da Conta. Outros utilizadores podem verificá-lo para iniciar uma conversa consigo. Tamanho Real Adicionar - Adicionar administradores Introduza o ID de Conta do utilizador que está a promover a administrador.\n\nPara adicionar vários utilizadores, introduza cada ID de Conta separado por vírgulas. Podem ser especificados até 20 IDs de Conta de cada vez. Admins não podem ser removidos. {name} e {count} outros foram promovidos a administradores. @@ -822,13 +821,9 @@ Ativado Já tem Agora pode enviar GIFs e imagens WebP animadas para a sua imagem de exibição! - Obtenha imagens de exibição animadas e desbloqueie funcionalidades premium com o {app_pro} Imagem de exibição animada os utilizadores podem carregar GIFs Carregue GIFs com - Quer enviar mensagens mais longas? Envie mais texto e desbloqueie funcionalidades premium com {app_pro} - Quer fixar mais conversas? Organize os seus chats e desbloqueie funcionalidades premium com o {app_pro} - Quer fixar mais de 5 conversas? Organize os seus chats e desbloqueie funcionalidades premium com {app_pro} Carregue imagens de exibição em GIF e WebP Conversas de grupo maiores com até 300 membros E muitas outras funcionalidades exclusivas @@ -840,7 +835,6 @@ Maior comprimento de mensagem Esta mensagem utilizou as seguintes funcionalidades do {app_pro}: Envie mais com - Quer aproveitar mais o {app_name}? Atualize para o {app_pro} e tenha uma experiência de mensagens mais poderosa. Perfil Exibir Imagem Erro ao remover a foto do perfil. diff --git a/app/src/main/res/values-b+ro+RO/strings.xml b/app/src/main/res/values-b+ro+RO/strings.xml index dc65f4480a..51fe52aa9b 100644 --- a/app/src/main/res/values-b+ro+RO/strings.xml +++ b/app/src/main/res/values-b+ro+RO/strings.xml @@ -15,7 +15,6 @@ Acesta este ID-ul tău de cont. Alți utilizatori îl pot scana pentru a începe o conversație cu tine. Mărime actuală Adaugă - Adaugă administratori Introdu ID-ul contului utilizatorului pe care îl promovezi ca administrator.\n\nPentru a adăuga mai mulți utilizatori, introduceți fiecare ID al contului separat prin virgulă. Pot fi specificate până la 20 de ID-uri de cont o dată. Administratorii nu pot fi eliminați. {name} și {count} alții au fost promovați la nivel de administrator. @@ -67,6 +66,7 @@ Note Acțiuni Vremea + Insigna {app_pro} Mod întunecat automat Ascunde bara de meniu Limba @@ -162,6 +162,7 @@ Ești sigur/ă că dorești să deblochezi pe {name} și alți {count}? Ești sigur/ă că dorești să deblochezi pe {name} și 1 altă persoană? {name} a fost deblocat/ă + Vezi și gestionează contactele blocate. Apelează {name} te-a apelat Nu poți iniția un apel nou. Termină mai întâi apelul actual. @@ -196,8 +197,10 @@ {app_name} are nevoie de acces la cameră pentru a realiza poze și clipuri video sau pentru a scana coduri QR. {app_name} are nevoie de acces la cameră pentru a scana coduri QR Anulare + Anulează {pro} Schimba Eroare la modificarea parolei + Modifică parola ta pentru {app_name}. Datele stocate local vor fi re-criptate cu noua ta parolă. Șterge Șterge tot Șterge toate datele @@ -288,6 +291,7 @@ Încă nu ai conversații Apasă Enter pentru a trimite Atingerea tastei Enter va trimite un mesaj în loc de a iniția o nouă linie. + Trimite cu Shift+Enter Toate fișierele media Verificare ortografie Activează verificarea ortografică pentru scrierea mesajelor. @@ -296,6 +300,7 @@ Copiază Creează Se creează apelul + Parola curentă Decupează Mod întunecat Ești sigur/ă că dorești să ștergi toate mesajele, atașamentele și datele contului de pe acest dispozitiv și să creezi un cont nou? @@ -403,6 +408,7 @@ {admin_name} a actualizat setările pentru mesajele temporare. Ai actualizat setările pentru mesaje temporare. Respinge + Ecran Poate fi numele tău real, un alias sau orice altceva dorești — și îl poți schimba oricând. Introduceți numele de afișare Introduceți numele care va fi afișat @@ -448,6 +454,7 @@ Mai e de lucru {emoji} Este grozav {emoji} Folosești {app_name} de ceva timp, cum ți se pare? Ne-ar face mare plăcere să aflăm părerea ta. + Intră Vă rugăm să verificați conexiunea la internet și să încercați din nou. Copiază eroare și închide aplicația Eroare de bază de date @@ -455,6 +462,8 @@ O eroare neașteptată a avut loc. Descărcarea a eșuat Erori + Feedback + Împărtășește experiența ta cu {app_name} completând un scurt sondaj. Fișier Fișiere Urmărește setările sistemului. @@ -565,12 +574,14 @@ Exportă jurnalele, apoi încarcă fișierul prin Serviciul de asistență {app_name}. Salvează pe desktop Asistență + Ajută la traducerea aplicației {app_name} în peste 80 de limbi! Ne-ar plăcea feedback-ul tău Ascunde Ești sigur/ă că dorești să ascunzi Notă personală din lista ta de conversații? Ascunde altele Imagine imagini + Important Tastatură incognito Solicită modul incognito, dacă este disponibil. În funcție de tastatura pe care o folosești, este posibil ca tastatura ta să ignore această solicitare. Info @@ -611,6 +622,7 @@ Nu veți avea protecție completă asupra metadatelor atunci când trimiteți previzualizări ale linkurilor. Previzualizările linkurilor sunt dezactivate {app_name} trebuie să contacteze site-urile web asociate prin link pentru a genera previzualizări ale linkurilor pe care le trimiteți și le primiți.\n\nPuteți activa funcția aceasta în setările {app_name}. + Linkuri Încarcă contul Se încarcă contul tău Se încarcă... @@ -623,6 +635,7 @@ Stare blocare Atingeți pentru a debloca {app_name} este deblocată + Loguri Gestionează membri Maximum Media @@ -649,8 +662,10 @@ Distribuie istoricul mesajelor Distribuie doar mesajele noi Invită + Bara de meniu Mesaj Citește mai mult + Copiază mesajul Mesajul este gol. Eroare la livrarea mesajului Limita de mesaje a fost atinsă @@ -717,7 +732,9 @@ Mesaj prea lung Te rugăm să scurtezi mesajul la {limit} caractere sau mai puțin. Mesaj prea lung + Parola nouă Următorul + Pașii următori Alege un pseudonim pentru {name}. Acesta va apărea în conversațiile individuale și de grup. Introduceți pseudonimul dorit Te rugăm să introduci un nume mai scurt @@ -798,8 +815,10 @@ Altele Parolă Schimbã Parola + Schimbă parola necesară pentru a debloca {app_name}. Parola ta a fost modificata. Securizați-va parola. Confirmă parola + Creează parolă Parola actuală este incorectă. Introduceți parola Vă rugăm să introduceţi parola actuală @@ -811,6 +830,7 @@ Parolă incorectă Confirmați noua parolă Elimină parolă + Elimină parola necesară pentru a debloca {app_name} Parola ta a fost ștearsă. Setează parola Parola ta a fost setata. Securizați-va parola. @@ -833,6 +853,7 @@ Permite accesul la cameră pentru apeluri video. Funcția de blocare a ecranului din {app_name} folosește Face ID. Păstrează activ in bară + {app_name} va continua să ruleze pe fundal după închiderea ferestrei. {app_name} are nevoie de acces la galeria foto pentru a continua. Puteți activa accesul în setările iOS. Accesul la rețeaua locală este necesar pentru a facilita apelurile. Activează permisiunea „Rețea locală” din Setări pentru a continua. {app_name} are nevoie de acces la rețeaua locală pentru a efectua apeluri vocale și video. @@ -859,18 +880,41 @@ Fixare conversație Anulați fixarea Anulați fixarea conversației + Și multe altele... + Funcționalități noi vor fi disponibile în curând pentru {pro}. Descoperă ce urmează în Fișa de parcurs {pro} {icon} Preferințe Previzualizare + Previzualizare notificare + Accesul tău {pro} este activ!\n\nAccesul tău {pro} se va reînnoi automat pentru încă {current_plan_length} pe data de {date}. + Accesul tău {pro} va expira pe {date}.\n\n Reînnoiește accesul tău {pro} acum pentru a te asigura că se va reînnoi automat înainte de expirarea accesului {pro}. + Accesul tău {pro} este activ!\n\nAccesul {pro} se va reînnoi automat pentru încă\n{current_plan_length} în data de {date}. Orice modificare făcută aici va intra în vigoare la următoarea reînnoire. + Accesul tău {pro} va expira pe {date}. + Accesul la {pro} nu putut fi găsit + Recuperează accesul {pro} + Reînnoiește accesul {pro} + Acces {pro} recuperat + {app_name} a detectat și a readus accesul {pro} pentru contul tău. Statutul tău {pro} a fost restabilit! Activat + Totul este gata! + Accesul tău la {app_pro} a fost actualizat! Vei fi facturat când {pro} se va reînnoi automat pe {date}. Deja ai Mergi mai departe și încarcă GIF-uri și imagini WebP animate pentru imaginea ta de profil! - Obține imagini de profil animate și deblochează funcționalități premium cu {app_pro} Poză de profil animată utilizatorii pot încărca GIF-uri + Poze de profil animate + Setează GIF-uri animate și imagini WebP animate ca imagine de profil. Încarcă GIF-uri cu - Vrei să trimiți mesaje mai lungi? Trimite mai mult text și deblochează funcții premium cu {app_pro} - Vrei mai multe fixări? Organizează-ți conversațiile și deblochează funcționalități premium cu {app_pro} - Vrei mai mult de 5 fixări? Organizează-ți conversațiile și deblochează funcționalități premium cu {app_pro} + {pro} se va reînnoi automat în {time} + Afișează insigna {app_pro} altor utilizatori + Insigne + Arată susținerea ta pentru {app_name} cu o insignă exclusivă afișată lângă numele tău. + Caracteristici {pro} Beta + Accesul tău {pro} are deja o reducere de {percent}% din prețul complet al {app_pro}. + Expirat + Din păcate, accesul tău {pro} a expirat. Reînnoiește-l pentru a reactiva beneficiile și funcționalitățile exclusive ale {app_pro}. + Expiră în curând + Întrebări frecvente {pro} + Găsește răspunsuri la întrebările frecvente în secțiunea {app_pro} FAQ. Încarcă imagini de profil GIF și WebP Conversații de grup mai mari, cu până la 300 de membri Și multe alte funcționalități exclusive @@ -878,11 +922,24 @@ Fixează un număr nelimitat de conversații Grup activat Acest grup are capacitate extinsă! Poate susține până la 300 de membri deoarece un administrator de grup are + Solicitarea unei rambursări este definitivă. Dacă este aprobată, accesul tău {pro} va fi anulat imediat și vei pierde accesul la toate funcționalitățile {pro}. Dimensiune mărită a atașamentului Lungime extinsă a mesajului + Grupuri mai mari + Grupurile în care ești administrator sunt actualizate automat pentru a permite 300 de membri. + Mesaje mai lungi + Poți trimite mesaje de până la 10.000 de caractere în toate conversațiile. Acest mesaj a folosit următoarele funcționalități {app_pro}: + Ne pare rău că pleci. Iată ce trebuie să știi înainte de a solicita o rambursare. + Se rambursează {pro} + Rambursare solicitată Trimite mai mult cu - Vrei să profiți mai mult de {app_name}? Fă upgrade la {app_pro} pentru o experiență de mesagerie mai puternică. + Statisticile tale {pro} + Statistica {pro} reflectă utilizarea pe acest dispozitiv și poate apărea diferit pe alte dispozitive conectate + Ai nevoie de ajutor cu {pro}? Trimite o solicitare către Echipa de Suport. + Prin actualizare, sunteți de acord cu Termenii și condițiile {icon} și Politica de confidențialitate {icon} ale {app_pro} + Pin-uri nelimitate + Organizează-ți toate conversațiile prin fixarea unui număr nelimitat de chaturi. Profil Afișează imaginea Nu s-a putut elimina imaginea de profil. @@ -930,9 +987,12 @@ Pentru a vă încărca contul, introduceți parola de recuperare. Ascunde definitiv Parola de recuperare Fără parola de recuperare, nu vă puteți încărca contul pe dispozitive noi. \n\nVă recomandăm insistent să salvați parola de recuperare într-un loc sigur și securizat înainte de a continua. + Ești sigur/ă că dorești ascunderea definitivă a parolei de recuperare pe acest dispozitiv?\n\nAceastă acțiune nu poate fi anulată. Ascunde Parolă de recuperare Ascunde definitiv parola de recuperare pe acest dispozitiv. Introduceți parola de recuperare pentru a încărca contul dvs. Dacă nu ați salvat-o, o puteți găsi în setările aplicației. + Vizualizează parola de recuperare + Vizibilitatea parolei de recuperare Aceasta este parola ta de recuperare. Dacă o trimiți cuiva, acea persoană va avea acces complet la contul tău. Recreează grup Repetă @@ -944,7 +1004,10 @@ Elimină Eroare la eliminarea parolei + Elimină parola actuală pentru {app_name}. Datele stocate local vor fi re-criptate cu o cheie generată aleatoriu, stocată pe dispozitivul tău. + Reînnoiește Răspunde + Solicită rambursare Retrimite Se încarcă informațiile despre țară... Repornește @@ -1001,10 +1064,12 @@ Notificări Permisiuni Confidenţialitate + {app_pro} Beta Parolă de recuperare Setări Setează Setează imaginea afișată de Comunitate + Setează o parolă pentru {app_name}. Datele stocate local vor fi criptate cu această parolă. Ți se va cere să introduci această parolă de fiecare dată când pornește {app_name}. Trebuie să reporniți {app_name} pentru a aplica noile setări. Securitate ecran Distribuie @@ -1018,22 +1083,29 @@ Afișează mai puțin Afișează Notă personală Ești sigur/ă că dorești să afișezi Notă personală în lista de conversații? + Verificator ortografic Autocolante Puternic + Întâmpini dificultăți? Consultă articole de ajutor sau deschide un tichet la serviciul de Suport {app_name}. Mergi la Pagina de asistență Informații de sistem: {information} Apasă pentru a reîncerca Continuă Implicit Eroare + Înapoi + Previzualizare temă ID-ul contului {name} este vizibil în baza interacțiunilor anterioare ID-urile cenzurate sunt utilizate în comunități pentru a reduce mesajele spam și a crește confidențialitatea + Traducere Încercați din nou Indicatori tastare Vizualizează și distribuie indicatorii de tastare. Indisponibil Anulează Necunoscut + Actualizează accesul {pro} + Două moduri de a actualiza accesul {pro}: Actualizări aplicație Actualizează informațiile comunității Numele și descrierea comunității sunt vizibile pentru toți membrii comunității @@ -1054,6 +1126,7 @@ Actualizare {app_name} Versiunea {version} Ultima actualizare acum {relative_time} + Actualizări Actualizare... Actualizează la Încărcare @@ -1061,6 +1134,7 @@ Deschide URL Aceasta se va deschide în browserul tău. Ești sigur/ă că dorești să deschizi acest URL în browserul tău?\n\n{url} + Linkurile se vor deschide în browserul tău. Folosește modul rapid Video Videoclipul nu poate fi redat. @@ -1073,4 +1147,6 @@ Fereastră Da Tu + Parola de recuperare + Ajustează dimensiunea textului și a elementelor vizuale. \ No newline at end of file diff --git a/app/src/main/res/values-b+ru+RU/strings.xml b/app/src/main/res/values-b+ru+RU/strings.xml index 7247fd421e..78bfc4f34b 100644 --- a/app/src/main/res/values-b+ru+RU/strings.xml +++ b/app/src/main/res/values-b+ru+RU/strings.xml @@ -15,7 +15,6 @@ Это ваш ID аккаунта. Другие пользователи могут сканировать его, чтобы начать беседу с вами. Фактический размер Добавить - Добавить администраторов Введите ID аккаунта пользователя, которого вы повышаете до администратора.\n\nЧтобы добавить нескольких пользователей, введите ID каждого аккаунта через запятую. Одновременно можно указать до 20 идентификаторов учётных записей. Администраторов нельзя удалить. {name} и {count} других пользователей назначены администраторами. @@ -916,13 +915,9 @@ Активирован У вас уже есть Вперёд! И загружай анимированные GIF и WebP для вашего изображения! - Получите анимированное изображение профиля и другие разблокированные премиум функции с {app_pro} Анимированное изображение профиля пользователи могут загружать GIF-файлы Загружайте GIF с - Хотите отправлять более длинные сообщения? Отправляйте больше текста и используйте премиум функции с {app_pro} - Хотите больше закреплений? Организуйте свои чаты и получайте доступ к премиум функциям с {app_pro} - Нужно более 5 закреплений? С {app_pro} организуйте свои чаты и получите доступ к премиум функциям Загрузка изображений в формате GIF и WebP Групповые чаты до 300 участников + множество эксклюзивных функций @@ -934,7 +929,6 @@ Увеличенная длина сообщения Это сообщение использовало следующие функции {app_pro}: Отправить еще с - Хотите больше возможностей от {app_name}? Перейдите на {app_pro}, чтобы получить более мощный опыт обмена сообщениями. Профиль Изображение профиля Не удалось удалить изображение профиля. diff --git a/app/src/main/res/values-b+sv+SE/strings.xml b/app/src/main/res/values-b+sv+SE/strings.xml index 506d6cce74..539f4bf52e 100644 --- a/app/src/main/res/values-b+sv+SE/strings.xml +++ b/app/src/main/res/values-b+sv+SE/strings.xml @@ -15,7 +15,6 @@ Detta är ditt konto-ID. Andra användare kan skanna det för att starta en konversation med dig. Aktuella storlek Lägg till - Lägg till administratörer Ange Account ID för användaren du gör till administratör.\n\nFör att lägga till flera användare, ange varje Account ID separerat med ett kommatecken. Upp till 20 Account ID:er kan anges åt gången. Administratörer kan inte tas bort. {name} och {count} andra blev befordrade till Admin. @@ -876,13 +875,9 @@ Aktiverat Du har redan Fortsätt och ladda upp GIF:ar och animerade WebP-bilder som visningsbild! - Skaffa animerade visningsbilder och lås upp premiumfunktioner med {app_pro} Animerad visningsbild användare kan ladda upp GIF:ar Ladda upp GIF:ar med - Vill du skicka längre meddelanden? Skicka mer text och lås upp premiumfunktioner med {app_pro} - Vill du ha fler fästen? Organisera dina chattar och lås upp premiumfunktioner med {app_pro} - Vill du ha mer än 5 fästisar? Organisera dina chattar och lås upp premiumfunktioner med {app_pro} Ladda upp GIF- och WebP-visningsbilder Större gruppchattar upp till 300 medlemmar Plus många fler exklusiva funktioner @@ -894,7 +889,6 @@ Förlängd meddelandelängd Detta meddelande använde följande funktioner från {app_pro}: Skicka mer med - Vill du få ut mer av {app_name}? Uppgradera till {app_pro} för en kraftfullare meddelandeupplevelse. Profil Visa bild Misslyckades med att ta bort visningsbild. @@ -958,6 +952,7 @@ Misslyckades med att ta bort lösenord Ta bort ditt nuvarande lösenord för {app_name}. Lokalt lagrad data kommer att krypteras om med en slumpmässigt genererad nyckel som lagras på din enhet. Svara + Begär återbetalning Skicka på nytt Läser in landinformation ... Starta om diff --git a/app/src/main/res/values-b+th+TH/strings.xml b/app/src/main/res/values-b+th+TH/strings.xml index 41f1b2e55e..d1b6cb1050 100644 --- a/app/src/main/res/values-b+th+TH/strings.xml +++ b/app/src/main/res/values-b+th+TH/strings.xml @@ -36,6 +36,9 @@ {name} ถูกปลดออกจากผู้ดูแลระบบ {name} และ {count} คนอื่นๆ ถูกลบออกจากสถานะ Admin. {name} และ {other_name} ถูกลบออกจากสถานะ Admin. + + กำลังส่งโปรโมชันของผู้ดูแลระบบ + การตั้งค่าผู้ดูแล {name} และ {other_name} ได้รับการเลื่อนตำแหน่งเป็นผู้ดูแลระบบ +{count} @@ -121,6 +124,7 @@ แบนผู้ใช้ แบนผู้ใช้แล้ว. บล็อก + เลิกปิดกั้นผู้ติดต่อนี้เพื่อส่งข้อความ ไม่มีผู้ติดต่อที่ถูกปิดกั้น บล็อก {name} คุณแน่ใจหรือไม่ว่าต้องการบล็อก {name}? ผู้ใช้ที่ถูกบล็อกจะไม่สามารถส่งคำร้องขอข้อความเชิญกลุ่ม หรือโทรหาคุณได้ @@ -385,6 +389,9 @@ ไม่สามารถเชิญ {name} และอีก {count} คนเข้าร่วม {group_name} ได้ ไม่สามารถเชิญ {name} และ {other_name} เข้าร่วม {group_name} ได้ ไม่สามารถเชิญ {name} เข้าร่วม {group_name} ได้ + + การส่งคำเชิญ + เชิญแล้ว เชิญเข้าร่วมกลุ่มสำเร็จ ผู้ใช้ต้องมีเวอร์ชันล่าสุดเพื่อรับคำเชิญ @@ -696,6 +703,9 @@ ทำซ้ำ เอาออก ลบรหัสผ่านล้มเหลว + + กำลังลบสมาชิก + ตอบกลับ ส่งอีกครั้ง กำลังโหลดข้อมูลประเทศ... diff --git a/app/src/main/res/values-b+tr+TR/strings.xml b/app/src/main/res/values-b+tr+TR/strings.xml index 098aa4f727..86103f9a3c 100644 --- a/app/src/main/res/values-b+tr+TR/strings.xml +++ b/app/src/main/res/values-b+tr+TR/strings.xml @@ -15,7 +15,6 @@ Bu sizin Hesap Kimliğiniz. Diğer kullanıcılar, sizinle bir oturum başlatmak için tarayabilir. Normal Boyut Ekle - Yönetici Ekle Yönetici olarak atadığınız kullanıcının Hesap Kimliğini girin.\n\nBirden fazla kullanıcı eklemek için her Hesap Kimliğini virgülle ayırarak girin. Tek seferde en fazla 20 Hesap Kimliği belirtilebilir. Yöneticiler kaldırılamaz. {name} ve {count} diğer yönetici olarak terfi etti. @@ -66,6 +65,7 @@ Notlar Borsa Hava Durumu + {app_pro} Rozet Otomatik karanlık tema Menü Çubuğunu Gizle Dil @@ -185,6 +185,7 @@ Aramalar (Beta) Sesli ve Görüntülü Aramalar Sesli ve Görüntülü Aramalar (Deneme Aşamasında) + Beta sürümünde arama yaparken IP adresiniz, aradığınız kişiye ve {session_foundation} sunucusuna görünür olacaktır. Diğer kullanıcılara ve diğer kullanıcılardan sesli ve görüntülü arama yapılmasını sağlar. {name} kullanıcısını aradınız {name} kişisinden gelen bir çağrıyı, Ses ve Video Görüşmeleri özelliğini Gizlilik Ayarlarında etkinleştirmediğiniz için kaçırdınız. @@ -195,6 +196,7 @@ {app_name}, fotoğraf ve video çekmek veya QR kodları taramak için kamera erişimine ihtiyaç duyar. {app_name}, QR kodlarını taramak için kamera erişimine ihtiyaç duyar. İptal + Değiştir Parola değiştirilemedi Temizle Hepsini Temizle @@ -233,6 +235,7 @@ Commit Hash: {hash} Bu, seçilen kullanıcıyı bu Topluluktan yasaklayacak ve tüm iletilerini silecek. Devam etmek istediğinizden emin misiniz? Bu, seçilen kullanıcıyı bu Topluluktan yasaklayacak. Devam etmek istediğinizden emin misiniz? + Grup açıklaması girin Topluluk URL\'si Girin Geçersiz URL Lütfen Topluluk URL\'sini kontrol edin ve tekrar deneyin. @@ -247,6 +250,8 @@ Bu topluluğun zaten üyesisiniz. Topluluktan Ayrıl {community_name} çıkış yapılamadı + Bir grup adı girin + Lütfen bir grup adı girin Bilinmeyen Community Topluluk URL\'si Topluluk URL\'sini Kopyala @@ -275,11 +280,15 @@ Konuşma silindi {conversation_name}\'de ileti yok. Giriş Tuşu + SHIFT + ENTER mesaj gönderir, ENTER yeni bir satıra geçer. + ENTER tuşu mesaj gönderir, SHIFT + ENTER yeni bir satıra geçer. Gruplar İleti kırpma Toplulukları Kırp + 2000\'den fazla mesaj içeren grup sohbetlerindeki 6 aydan önce gönderilmiş mesajları otomatik silin. Yeni Konuşma Henüz herhangi bir konuşmanız yok + Enter tuşu ile gönder Enter tuşuna basmak yeni bir satıra geçmek yerine ileti gönderir. Tüm Medya Yazım Denetimi @@ -314,6 +323,10 @@ Grup oluşturulurken lütfen bekleyin Grup güncellenemedi Başkalarının iletilerini silmek için yetkiniz bulunmamaktadır + + Seçili Eki Sil + Seçilen ekleri sil + {name} kişisini kişilerinizden silmek istediğinizden emin misiniz?\n\nBu işlem, tüm mesajlar ve ekler dahil olmak üzere sohbetinizi silecektir. {name} kişisinden gelen gelecekteki mesajlar, mesaj isteği olarak görünecektir. {name} ile olan sohbetinizi silmek istediğinizden emin misiniz?\nBu işlem, tüm mesajları ve ekleri kalıcı olarak silecektir. @@ -431,6 +444,7 @@ {app_name}\'i beğendiniz mi? Geliştirilmesi Gerekiyor {emoji} Harika {emoji} + Bir süredir {app_name} uygulamasını kullanıyorsunuz, nasıl gidiyor? Düşüncelerinizi duymaktan çok memnun oluruz. Lütfen internet bağlantınızı kontrol edin ve tekrar deneyin. Hata Kopyala ve Çık Veritabanı Hatası @@ -440,12 +454,15 @@ Hatalar Dosya Dosyalar + Sistem ayarlarını kullan. Sonsuza dek Kimden: Tam Ekranı Aç/Kapat GIF Giphy {app_name}, arama sonuçları sağlamak için Giphy\'ye bağlanacaktır. GIF gönderirken tüm meta veri korumasına sahip olmayacaksınız. + Geri Bildirim Gönder? + {app_name} deneyiminizin iyi olmamasına üzüldük. Kısa bir anketle düşüncelerinizi paylaşırsanız memnun oluruz Gruplar en fazla 100 üyeye sahiptir Grup Oluştur Lütfen en az 1 grup üyesi seçin. @@ -541,13 +558,16 @@ Günlüğu Dışa Aktar Günlüklerinizi dışa aktarın, ardından dosyayı {app_name}\'ın Yardım Masası üzerinden yükleyin. Masaüstüne kaydet + Bu dosyayı kaydedin, ardından {app_name} geliştiricileriyle paylaşın. Destek Görüşlerinizi almak isteriz Gizle + Sistem menü çubuğu görünürlüğünü değiştirin. Kendime Not\'u sohbet listenizden gizlemek istediğinizden emin misiniz? Diğerlerini Gizle Görüntü resimler + Önemli Gizli Klavye Gizli mod iste. Kullandığınız klavyeye bağlı olarak klavyeniz bu isteği göz ardı edebilir. Bilgi @@ -761,8 +781,12 @@ Bu ONS\'u tanıyamadık. Lütfen kontrol edin ve tekrar deneyin. Bu ONS\'u arayamadık. Lütfen daha sonra tekrar deneyin. + Anketi Aç Diğer + Şifre Parolayı Değiştir + {app_name} kilidini açmak için gereken şifreyi değiştirin. + Şifreniz değiştirildi. Lütfen güvenle saklayınız. Şifreyi Doğrula Şifre Oluştur Mevcut şifreniz yanlış. @@ -770,11 +794,19 @@ Lütfen mevcut parolanızı girin Lütfen yeni parolanızı girin Parolanız sadece harf, sayı ve sembol içermelidir + Şifreniz {min} ila {max} karakter uzunluğunda olmalıdır Şifreler uyuşmuyor Parolayı ayarlama başarısız oldu Yanlış şifre + Yeni Şifreyi Onayla Parolayı Kaldır + Şifreniz kaldırıldı. Şifre Belirle + Şifreniz ayarlandı. Lütfen güvenle saklayınız. + {app_name} kilidini açmak için şifre iste. + Şifre Güç Göstergesi + Güçlü bir şifre ayarlamak, cihazınız kaybolur veya çalınırsa mesajlarınızı ve eklerinizi korumanıza yardımcı olur. + Şifreler Yapıştır İzin Değişimi {app_name} dosya, müzik ve ses gönderimi için müzik ve ses erişimine ihtiyaç duyuyor, ancak bu erişim kalıcı olarak reddedildi. Ayarlar → İzinler üzerine dokunun ve \"Müzik ve ses\" seçeneğini açın. @@ -814,24 +846,60 @@ Konuşmayı sabitle Sabitlemeyi Kaldır Sohbetin Sabitlemesini Kaldır + Çok Daha Fazlası... + {pro} için yakında yeni özellikler geliyor. {pro} Yol Haritası\'nda gelecek yenilikleri keşfedin {icon} + Tercihler Ön İzleme Etkinleştirildi Zaten sahipsiniz Hadi, profil resminiz için GIF\'ler ve animasyonlu WebP görselleri yükleyin! - Animasyonlu profil resimleri edinin ve {app_pro} ile premium özelliklerin kilidini açın Profil Onur Seçin kullanıcılar GIF yükleyebilir + Animasyonlu Profil Resimleri + GIF ve WebP görsellerini profil resminiz olarak ayarlayın. ile GIF Yükleyin - Daha uzun mesajlar mı göndermek istiyorsunuz? {app_pro} ile daha fazla metin gönderin ve premium özelliklerin kilidini açın - Daha fazla sabitleme mi istiyorsunuz? Sohbetlerinizi düzenleyin ve {app_pro} ile premium özelliklerin kilidini açın - 5\'ten fazla sabitleme mi istiyorsunuz? Sohbetlerinizi düzenleyin ve {app_pro} ile premium özelliklerin kilidini açın + {pro} {time} içinde otomatik olarak yenileniyor + {app_pro} rozetini diğer kullanıcılara göster + Rozetler + Görünen adınızın yanında özel bir rozetle {app_name} uygulamasını desteklediğinizi gösterin. + + %1$s %2$s Gönderilen Rozet + %1$s %2$s Gönderilen Rozetler + + {pro} Beta Özellikler + {app_pro} SSS bölümünde sık sorulan soruların yanıtlarını bulun. GIF ve WebP profil resmi yükleme 300 üyeye kadar daha büyük grup sohbetleri Ayrıca daha birçok özel özellik 10.000 karaktere kadar mesajlar Sınırsız sohbet sabitleme + Grup Etkinleştirildi + Bu grubun kapasitesi artırıldı! Bir grup yöneticisi sayesinde artık 300 üyeye kadar destekleyebilir + + %1$s Grup Yükseltildi + %1$s Grup Yükseltildi + + Artırılmış Ek Boyutu + Artırılmış Mesaj Uzunluğu + Daha Büyük Gruplar + Yöneticisi olduğunuz gruplar otomatik olarak 300 üyeye kadar destekleyecek şekilde yükseltilir. + Uzun Mesajlar + Tüm sohbetlerde 10.000 karaktere kadar mesaj gönderebilirsiniz. + + %1$s Uzun Mesaj Gönderildi + %1$s Uzun Mesaj Gönderildi + + Bu mesajda aşağıdaki {app_pro} özellikleri kullanıldı: + + %1$s Sabitlenmiş Sohbet + %1$s Sabitlenmiş Sohbetler + + Seni kaybettiğimize üzüldük. İşte para iadesi talep etmeden önce bilmen gerekenler. ile daha fazlasını gönderin - {app_name}\'den daha fazla yararlanmak ister misiniz? Daha güçlü bir mesajlaşma deneyimi için {app_pro}\'ya yükseltin. + {pro} İstatistikleriniz + {pro} istatistikleri bu cihazdaki kullanımı yansıtır ve bağlanan diğer cihazlarda farklı görünebilir + Sınırsız Sabitleme + Sınırsız sohbet sabitleme özelliğiyle tüm sohbetlerinizi organize edin. Profil Profil Resmini Seçin Profil resmi kaldırılamadı. @@ -855,6 +923,9 @@ Arkadaşlarınız, QR kodunuzu tarayarak size ileti gönderebilir. {app_name}\'i Kapat Çık + {app_name}\'ı oylayın? + Uygulamayı Oyla + {app_name}\'ı beğenmenize sevindik, bir dakikanız varsa {storevariant}\'da bize oy verin. Başkalarının da özel ve güvenli mesajlaşmayı keşfetmesine yardımcı olun. Okundu Okundu Bilgileri Gönderdiğiniz ve aldığınız tüm iletiler için okundu bilgilerini göster. @@ -895,6 +966,7 @@ Yeniden başlat Yeniden Senkronize Et Yeniden Dene + Görünüşe göre {app_name} uygulamasını yakın zamanda zaten değerlendirmişsiniz, geri bildiriminiz için teşekkürler! Kaydet Kaydedildi Kaydedilen iletiler @@ -961,6 +1033,7 @@ Kendime Notu Göster Kendime Not\'u sohbet listenizde göstermek istediğinizden emin misiniz? Çıkartmalar + Güç Destek Sayfasına Git Sistem Bilgisi: {information} Tekrar denemek için tıkla @@ -976,6 +1049,10 @@ Geri al Bilinmeyen Uygulama güncellemeleri + Grup Bilgilerini Güncelle + Grup adı ve açıklaması tüm grup üyeleri tarafından görülebilir + Lütfen daha kısa bir grup açıklaması girin + Lütfen daha kısa bir grup adı girin Güncelleme yüklendi, yeniden başlatmak için tıklayın Güncelleme indiriliyor: {percent_loader}% Güncellenemiyor @@ -985,6 +1062,8 @@ Lütfen daha kısa bir grup açıklaması girin {app_name} yeni bir sürümü mevcut, güncellemek için dokunun {app_name} uygulamasının yeni ({version}) sürümü kullanılabilir. + Profil Bilgilerini Güncelle + Görünen adınız ve profil resminiz tüm sohbetlerde görünür. Sürüm Notlarına Git {app_name} Güncellemesi Sürüm {version} diff --git a/app/src/main/res/values-b+uk+UA/strings.xml b/app/src/main/res/values-b+uk+UA/strings.xml index 4939521619..ee44db3887 100644 --- a/app/src/main/res/values-b+uk+UA/strings.xml +++ b/app/src/main/res/values-b+uk+UA/strings.xml @@ -15,7 +15,6 @@ Це ваш Account ID. Інші користувачі можуть просканувати його, щоб почати розмову з вами. Актуальний розмір Додати - Додати адміністраторів Введіть ідентифікатор облікового запису користувача, якого ви призначаєте адміністратором.\n\nЩоб додати кількох користувачів, введіть ідентифікатори їхніх облікових записів, розділяючи їх комами. Одночасно можна вказати до 20 ідентифікаторів облікових записів. Адміністратори не можуть бути видалені. {name} та ще {count} інших було підвищено до адміністраторів. @@ -193,6 +192,10 @@ Дозволяє голосові та відеодзвінки з іншими користувачами. Ви дзвонили {name} Ви пропустили дзвінок від {name}, бо не увімкнули Голосові та відеодзвінки у налаштуваннях конфіденційності. + {app_name} потребує доступу до вашої камери для відеодзвінків, але такий дозвіл не було надано. Ви не можете надати дозвіл на використання камери під час дзвінка.\n\nБажаєте завершити дзвінок зараз і надати доступ до камери, або бажаєте отримати нагадування після завершення дзвінка? + Щоб надати доступ до камери, відкрийте налаштування та надайте дозвіл на використання застосунку Камера. + Під час вашого останнього дзвінка ви намагалися використати відеозв\'язок, але не змогли, оскільки доступ до камери було раніше заборонено. Щоб дозволити доступ до камери, відкрийте налаштування та надайте дозвіл на використання камери. + Потрібен доступ до камери Камеру не знайдено Камера недоступна. Дозволити доступ до камери @@ -200,10 +203,14 @@ {app_name} потребує доступ до камери, щоб фотографувати, знімати відео або сканувати QR-коди. {app_name} потрібен дозвіл до камери для сканування QR-кодів Скасувати - Скасувати тарифний план + Скасувати {pro} + Скасувати план {pro} Змінити Не вдалося змінити пароль Змінити ваш пароль для {app_name}. Локально збережені дані будуть наново шифровані з застосуванням нового паролю. + Перевірка статусу {pro} + Перевірка статусу {pro}... + Перевіряємо ваші дані {pro}. Ви не можете продовжити передплату, доки ця перевірка не буде завершена. Очистити Очистити всі Очистити всі дані @@ -310,7 +317,6 @@ Створити Викликаємо Поточний пароль - Поточна передплата Вирізати Темний режим Ви впевнені, що хочете видалити всі повідомлення, вкладення та дані облікового запису з цього пристрою та створити новий обліковий запис? @@ -468,7 +474,9 @@ Ви та {name} відреагували з {emoji_name} Відреагували на ваше повідомлення {emoji} Увімкнути + Дозволити доступ до Камери? Показувати сповіщення, коли ви отримуєте нові повідомлення. + Завершіть виклик для увімкнення Подобається {app_name}? Потребує доопрацювання {emoji} Крутяк {emoji} @@ -478,6 +486,7 @@ Скопіювати помилку та вийти Помилка бази даних Щось пішло не так. Будь ласка, спробуйте пізніше. + Помилка завантаження доступу до {pro} Невідома помилка Не вдалося завантажити Відмови @@ -780,6 +789,8 @@ Встановити псевдонім Ні Немає припущень + Надсилайте повідомлення до 10 000 символів у всіх розмовах. + Організовуйте вікно розмов з необмеженою кількістю закріплених бесід. Жодного Не зараз Нотатка для себе @@ -830,6 +841,9 @@ Добре Увімк. На вашому пристрої {device_type} + На зв\'язаному пристрої + На вебсайті {platform_store} + На вебсайті {platform} Створити обліковий запис Обліковий запис створено Я маю обліковий запис @@ -854,7 +868,8 @@ Не вдалося розпізнати цей ONS. Будь ласка, перевірте його та спробуйте ще раз. Не вдалося виконати пошук цього ONS. Спробуйте пізніше. Відкрити - Відкрити {platform_store} вебсайт + Відкрити {platform} вебсайт + Відкрити налаштування Пройти опитування Інші Пароль @@ -931,12 +946,12 @@ Налаштування Попередній перегляд Попередній перегляд сповіщень + Помилка доступу {pro} + Доступ до {pro} завантажується... активовано Готово! - Твою підписку {app_pro} оновлено. {date} коли підписку {pro} буде подовжено, тоді й стягнуть гроші. У вас вже є Не зволікайте і завантажуйте GIF та анімовані WebP картинки для свого аватара! - Отримайте анімовані аватари та розблокуйте преміальні функції з {app_pro} Анімоване зображення профілю користувачі можуть завантажувати GIF Анімовані зображення облікового запису @@ -951,14 +966,10 @@ {price} сплата щорічно {price} сплата щомісячно {price} сплата щоквартально - Хочете відправляти довші повідомлення? Надсилайте більше тексту та розблокуйте преміальні функції застосунку з Session Pro - Потрібно більше закріплених бесід? Впорядкуйте свої чати та розблокуйте преміальні функції з {app_pro} - Потрібно понад 5 закріплених бесід? Впорядкуйте свої бесіди та розблокуйте преміальні функції з {app_pro} - На поточну підписку ти вже маєш знижку {percent}% від загальної ціни {app_pro}. + Скасування + Помилка оновлення {pro} Підписка сплила - На жаль, підписка {pro} сплила. Онови її задля збереження переваг і можливостей {app_pro}. Невдовзі спливе підписка - Підписка {pro} спливе {time}. Онови підписку задля збереження переваг і можливостей {app_pro}. {pro} спливає за {time} {pro} ЧАП Відповіді на загальні запитання знайдеш у ЧаПи {app_pro}. @@ -969,41 +980,41 @@ Закріплюйте необмежену кількість бесід Групу активовано У цієї групи розширено можливості! Тепер вона може вміщати до 300 учасників, тому що адміністратор групи має - Вимагання повернення грошей закінчено. В разі схвалення твою підписку {pro} негайно скасують і ти втратиш всі можливості {pro}. Збільшений розмір вкладення Збільшена довжина повідомлень Більші групи Групи, у яких ви є адміністратором, автоматично оновлюються для підтримки до 300 учасників. + Незабаром усі користувачі Pro Beta зможуть створювати більші групові чати (до 300 учасників)! Довші повідомлення Ви можете надсилати повідомлення до 10 000 символів у всіх розмовах. У цьому повідомленні наявні наступні функції Session Pro: + Наразі є два способи поновлення доступу: {percent}% Знижки - Для тебе діє підписка {app_pro}.\n\n{date} твою підписку буде самодійно поновлено як {current_plan}. Оновлення підписки настане під час наступного оновлення {pro}. - Для тебе діє підписка {app_pro}.\n\n{date} твою підписку буде самодійно поновлено як {current_plan}. - Твоя підписка {app_pro} спливе {date}.\n\nДля збереження особливих можливостей подовж свою підписку. - Підписка {app_pro} спливе {date}. - Передплата {pro} не знайдена - Відновити передплату {pro} - Оновити підписку {pro} - План {pro} відновлено - Виявлено дійсний план {app_pro}, та ваш статус {pro} було відновлено! 1 місяць — {monthly_price} / місяць 3 місяці — {monthly_price} / місяць 12 місяців – {monthly_price} / місяць Шкода, же ти передумав(ла). Перед вимогою повернення грошей ти мусиш знати ось що. Повернення грошей за {pro} + Хочете знову використати {app_name} на повну?\nПоновіть доступ до {pro}, щоб розблокувати функції, яких вам бракувало. + Хочете знову закріпити більше розмов?\nПоновіть свій доступ до {pro}, щоб розблокувати функції, яких вам бракувало. + Помилка поновлення Pro, повторна спроба незабаром Вимогу повернення грошей надіслано Надсилайте довші повідомлення з Налаштування {pro} Ваша статистика {pro} + Завантажується статистика плану {pro} + Ваша статистика плану {pro} завантажується, зачекайте, будь ласка. Звіти підписки {pro} відображають використання лише цього пристрою, тож, мабуть, матимуть иншого вигляду на инших пристроях - Якщо потребуєш допомоги щодо підписки {pro}, надійшли звернення до відділу підтримки. + Помилка статусу {pro} + Не вдалося під\'єднатися до мережі для перевірки вашого статусу {pro}. Інформація, демонстрована на цій сторінці, може бути неточною, доки не буде відновлено з\'єднання.\n\nБудь ласка, перевірте з\'єднання з мережею та спробуйте ще раз. + Завантажується статус плану {pro} + Завантаження {pro} + Потрібна допомога з {pro}? Надішліть запит до служби підтримки. Цією дією ти надаси згоду щодо дотримання Правил послуги {app_pro} {icon} і Ставлення до особистих відомостей {icon} Необмежена кількість закріплених бесід Закріплення необмеженої кількості співрозмовників в головному переліку. - Ваш план завершиться {date}.\n\nПісля оновлення налаштувань автоматичної оплати, ваш план автоматично подовжиться {date} на додатковий {selected_plan} доступу до Pro. - Хочете отримати більше від {app_name}? Оновіться до {app_pro}, щоб мати потужніший досвід обміну повідомленнями. - {platform_account} опрацьовує ваш запит на відшкодування + Наразі єдиний шлях підвищити рівень: + {platform} опрацьовує ваш запит на відшкодування Профіль Аватар Не вдалося видалити зображення профілю @@ -1070,6 +1081,7 @@ %1$d символів залишилось %1$d символів залишилось + Нагадати пізніше Видалити Не вдалося видалити пароль Видаліть свій поточний пароль для {app_name}. Локально збережені дані буде повторно зашифровано випадково згенерованим ключем, який зберігатиметься на вашому пристрої. @@ -1178,6 +1190,8 @@ Недоступно Назад Невідомо + Непідтримуваний ЦП + Оновити Оновлення застосунку Оновити інформацію про спільноту Назву та опис спільноти бачать усі учасники @@ -1192,8 +1206,6 @@ Будь ласка, введіть коротший опис групи Нова версія {app_name} доступна. Доступна нова версія ({version}) {app_name}. - Оновити тарифний план - Два шляхи поновлення твоєї підписки: Оновити інформацію облікового запису Ваше відображуване ім’я та зображення профілю видимі у всіх розмовах. Перейти в примітки до випуску @@ -1202,6 +1214,8 @@ Останнє оновлення {relative_time} тому Оновлення Оновлення... + Підвищити + Покращити {app_name} Підвищити до Вивантаження Копіювати URL @@ -1210,7 +1224,7 @@ Ви впевнені, що хочете відкрити цю URL-адресу у своєму браузері?\n\n{url} За ланкою перейде твоє оглядало мережців за промовчання. Використовувати швидкий режим - Через вебсайт {platform_store} + Через вебсайт {platform} Відео Не вдається відтворити відео. Вигляд @@ -1219,9 +1233,11 @@ Це може зайняти кілька хвилин. Будь ласка, зачекайте... Попередження + Підтримка iOS 15 закінчилася. Оновіться до iOS 16 або новішої версії для продовження отримання оновлень застосунку. Вікно Так Ви + Ваш процесор не підтримує інструкції SSE 4.2, які необхідні для роботи {app_name} на операційних системах Linux x64 для обробки зображень. Будь ласка, оновіть процесор до сумісного або скористайтеся іншою операційною системою. Ваш пароль для відновлення Масштаб Налаштування розміру тексту та візуальних елементів. diff --git a/app/src/main/res/values-b+zh+CN/strings.xml b/app/src/main/res/values-b+zh+CN/strings.xml index 14375a7b0e..86bef95069 100644 --- a/app/src/main/res/values-b+zh+CN/strings.xml +++ b/app/src/main/res/values-b+zh+CN/strings.xml @@ -15,7 +15,6 @@ 这是您的账户ID。其他用户可以扫描它来与您开始会话。 实际尺寸 添加 - 添加管理员 请输入您正在授权为管理员的用户的帐户 ID。\n\n要添加多个用户,请输入用逗号分隔的每个帐户 ID。一次最多可以指定20个帐户 ID。 管理员无法被移除。 {name}和其他{count}名成员被设置为管理员。 @@ -65,6 +64,7 @@ 笔记 股票 天气 + 自动深色模式 隐藏菜单栏 语言 选择{app_name}的语言设置。更改语言设置后{app_name}将重新启动。 @@ -159,6 +159,7 @@ 您确定要取消屏蔽{name}和其他{count}人吗? 您确定要取消屏蔽{name}和其他1人吗? 取消屏蔽{name} + 查看和管理已屏蔽的联系人。 语音通话 {name}呼叫过您 您无法开始新的通话。请先结束当前的通话。 @@ -193,6 +194,7 @@ {app_name}需要相机权限来拍摄照片和视频,或扫描二维码。 {app_name}需要相机访问权限才能扫描二维码 取消 + 更改 更改密码失败 清除 清除所有 @@ -281,6 +283,7 @@ 新建会话 您还没有任何会话 按回车键发送消息而非换行 + 使用 Shift + Enter 键发送 所有媒体 拼写检查 在输入消息时启用拼写检查。 @@ -289,7 +292,9 @@ 复制 创建 正在创建通话 + 当前密码 剪切 + 深色模式 您确定要删除此设备上的所有消息、附件和帐户数据,并创建新帐户吗? 发生数据库错误。\n\n请导出您的应用日志以进行故障排除。如果不成功,请重新安装{app_name}并恢复您的帐户。 您确定要删除此设备上的所有消息、附件和帐户数据,并从网络中恢复你的帐户吗? @@ -381,6 +386,7 @@ {admin_name}更改了阅后即焚消息设置。 更改了阅后即焚消息设置。 放弃 + 显示 它可以是您的真名、化名或任何您喜欢的内容——并且您可以随时进行更改。 输入您想显示的名称 请输入显示名称 @@ -424,6 +430,7 @@ 需要改进 {emoji} 很棒 {emoji} 您已使用 {app_name} 一段时间了,感觉如何?非常希望能听到您的反馈。 + 进入 请检查您的网络连接并重试。 复制错误并退出 数据库错误 @@ -431,8 +438,11 @@ 发生了未知错误。 下载失败 失败 + 反馈 + 通过完成一份简短的调查问卷分享您对 {app_name} 的使用体验。 文件 文件 + 跟随系统设置。 永久 发送自: 全屏 @@ -533,8 +543,10 @@ 导出您的日志,然后通过{app_name}的帮助服务台上传日志。 保存到桌面 支持 + 帮助将 {app_name} 本地化翻译成超过 80 种语言! 感谢您的反馈 隐藏 + 切换系统菜单栏可见性。 你确定要从对话列表中隐藏Note to Self吗? 隐藏其它 图片 @@ -572,6 +584,7 @@ 您无法在绝对的元数据安全保障前提下发送链接预览。 链接预览已关闭 {app_name}必须访问链接的网站以生成您发送和接收的链接预览。\n\n您可以在{app_name}的设置中启用该功能。 + 链接 加载账户 正在加载您的账户 正在加载... @@ -584,6 +597,7 @@ 锁定状态 点击解锁 {app_name}已解锁 + 日志 管理成员 最大 媒体 @@ -606,6 +620,7 @@ 邀请 消息 了解更多 + 复制消息 此消息内容为空。 消息发送失败 消息字数已满 @@ -664,6 +679,7 @@ 消息太长 请将消息缩短至 {limit} 个字符或更少。 消息太长 + 新密码 下一步 {name}选择一个昵称。该昵称将在您的一对一和群组对话中显示。 输入昵称 @@ -678,6 +694,7 @@ 您在备忘录中没有消息。 隐藏备忘录 您确定要隐藏备忘录吗? + 通知显示 所有信息 通知内容 在通知中显示的信息。 @@ -742,8 +759,11 @@ 打开 打开调查问卷 其它 + 密码 更改密码 + 更改 {app_name} 的解锁密码。 确认密码 + 创建密码 您当前的密码不正确。 请输入密码 请输入您当前的密码 @@ -753,8 +773,14 @@ 密码不一致 设置密码失败 密码不正确 + 确认新密码 移除密码 + 您的密码已被移除。 设置密码 + 长于12个字符 + 密码强度指标 + 设置强密码有助于在设备丢失或被盗时保护您的消息和附件。 + 密码 粘贴 授权变更 {app_name}需要音乐和音频权限才能发送文件、音乐和音频,但该权限已被永久拒绝。请进入应用程序设置→权限,打开“音乐和音频”权限。 @@ -767,6 +793,7 @@ 请允许访问摄像头以进行视频通话。 {app_name}的屏幕锁功能使用 Face ID。 保留在系统托盘 + 当您关闭窗口,{app_name}将在后台继续运行。 {app_name}需要照片库访问权限以继续。您可以在iOS设置中启用访问。 需要本地网络访问权限才能进行通话。在设置中允许“本地网络”权限以继续。 {app_name}需要访问本地网络才能进行语音和视频通话。 @@ -793,17 +820,15 @@ 置顶会话 取消置顶 取消置顶会话 + 偏好设置 通知效果预览 + 预览通知 已激活 您已拥有 快去为头像上传 GIF 或动画 WebP 图片吧! - 获取动画头像并使用 {app_pro} 解锁高级功能 动画头像 用户可上传 GIF 使用 PRO 上传 GIF - 想发送更长的消息?使用 {app_pro} 发送更多文本并解锁高级功能 - 想要固定更多对话?使用 {app_pro} 整理你的聊天并解锁高级功能 - 想要固定超过 5 个对话?使用 {app_pro} 整理你的聊天并解锁高级功能 上传 GIF 和 WebP 头像 更大的群组聊天,最多可容纳 300 名成员 还有更多专属功能等你解锁 @@ -813,9 +838,9 @@ 该群组已扩容!因管理员升级为 PRO,现支持最多 300 名成员 附件大小已增加 消息长度已增加 + 您作为管理员的群组自动升级成支持300名成员。 此消息使用了以下 {app_pro} 功能: 发送更多内容,体验 - 想充分体验 {app_name}?升级到 {app_pro} 享受更强大的消息体验。 个人资料 头像 移除头像失败。 @@ -944,12 +969,15 @@ 显示备忘录 你确定要在对话列表中显示 Note to Self吗? 贴图 + 强度 + 遇到问题?浏览帮助文章或向 {app_name} 支持提交工单。 跳转到支持页面 系统信息:{information} 点击以重试 继续 默认 错误 + 主题预览 基于您与 {name} 之前的互动,其 Account ID 对您可见 盲化 ID 在社区中用于减少垃圾信息并提高隐私性 重试 @@ -972,6 +1000,7 @@ 请输入更简短的群组描述 有新版本的{app_name}可用,点击更新 {app_name}有新版本({version})可用。 + 更新个人资料信息 跳转到版本信息 {app_name}更新 版本 {version} @@ -994,4 +1023,6 @@ 窗口 + 缩放系数 + 调整文本和视觉元素的大小。 \ No newline at end of file diff --git a/app/src/main/res/values-b+zh+TW/strings.xml b/app/src/main/res/values-b+zh+TW/strings.xml index 164ca371f2..e9b6932cc6 100644 --- a/app/src/main/res/values-b+zh+TW/strings.xml +++ b/app/src/main/res/values-b+zh+TW/strings.xml @@ -15,7 +15,6 @@ 這是您的帳號 ID。其他使用者可以掃描來與你對話。 實際大小 新增 - 新增管理員 請輸入您要晉升為管理員的使用者的 Account ID。\n\n若要新增多位使用者,請輸入以逗號分隔的每個 Account ID。一次最多可指定 20 個 Account ID。 無法移除管理員。 {name}{count} 位其他成員 被設置為管理員。 @@ -800,13 +799,9 @@ 已啟用 您已擁有 您可以為您的顯示圖片上傳 GIF 或動畫 WebP 圖片了! - 取得動畫顯示圖片並透過 {app_pro} 解鎖進階功能 動畫顯示圖片 用戶可以上傳 GIF 使用 {app_pro} 上傳 GIF 圖片 - 想傳送更長的訊息嗎?與 {app_pro} 一起傳送更多文字並解鎖進階功能 - 想要釘選更多對話嗎?使用 {app_pro} 整理您的聊天並解鎖進階功能 - 想要釘選超過 5 則對話嗎?使用 {app_pro} 整理您的聊天並解鎖進階功能 上傳 GIF 和 WebP 顯示圖片 最大支援 300 位成員的大型群組聊天室 以及更多獨家功能 @@ -818,7 +813,6 @@ 訊息長度提升 本訊息使用了以下 {app_pro} 功能: 升級後可傳送更多內容 - 想要充分利用 {app_name}?升級為 {app_pro},享受更強大的訊息體驗。 個人檔案 顯示圖片 無法刪除顯示圖片。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d6f334233c..db363a5f9a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,8 +16,12 @@ This is your Account ID. Other users can scan it to start a conversation with you. Actual Size Add - Add Admins + + Add Admin + Add Admins + Enter the Account ID of the user you are promoting to admin.\n\nTo add multiple users, enter each Account ID separated by a comma. Up to 20 Account IDs can be specified at a time. + Admins cannot be demoted or removed from the group. Admins cannot be removed. {name} and {count} others were promoted to Admin. Promote Admins @@ -42,12 +46,18 @@ {name} was removed as Admin. {name} and {count} others were removed as Admin. {name} and {other_name} were removed as Admin. + + %1$d Admin Selected + %1$d Admins Selected + Sending admin promotion Sending admin promotions Admin Settings + You cannot change your admin status. To leave the group, open the conversation settings and select Leave Group. {name} and {other_name} were promoted to Admin. + Admins +{count} Anonymous App Icon @@ -192,6 +202,10 @@ Enables voice and video calls to and from other users. You called {name} You missed a call from {name} because you haven\'t enabled Voice and Video Calls in Privacy Settings. + {app_name} needs access to your camera to enable video calls, but this permission has been denied. You can’t update your camera permissions during a call.\n\nWould you like to end the call now and enable camera access, or would you like to be reminded after the call? + To allow camera access, open settings and turn on the Camera permission. + During your last call, you tried to use video but couldn’t because camera access was previously denied. To allow camera access, open settings and turn on the Camera permission. + Camera Access Required No camera found Camera unavailable. Grant Camera Access @@ -199,12 +213,18 @@ {app_name} needs camera access to take photos and videos, or scan QR codes. {app_name} needs camera access to scan QR codes Cancel - Cancel Plan + Cancel {pro} + Cancel {pro} Plan + Cancel on the {platform} website, using the {platform_account} you signed up for {pro} with. + Cancel on the {platform_store} website, using the {platform_account} you signed up for {pro} with. Change Failed to change password Change your password for {app_name}. Locally stored data will be re-encrypted with your new password. Checking {pro} Status - Checking your {pro} details. Some information on this page may be inaccurate until this check is complete. + Checking your {pro} status. You\'ll be able to continue once this check is complete. + Checking your {pro} details. Some actions on this page may be unavailable until this check is complete. + Checking {pro} Status... + Checking your {pro} details. You cannot renew until this check is complete. Checking your {pro} status. You\'ll be able to upgrade to {pro} once this check is complete. Clear Clear All @@ -264,11 +284,17 @@ Community URL Copy Community URL Confirm + Confirm Promotion + Are you sure? Admins cannot be demoted or removed from the group. Contacts Delete Contact Are you sure you want to delete {name} from your contacts? New messages from {name} will arrive as a message request. You don\'t have any contacts yet Select Contacts + + %1$d Contact Selected + %1$d Contacts Selected + User Details Camera Choose an action to start a conversation @@ -309,8 +335,8 @@ Copy Create Creating Call + Current Billing Current Password - Current Plan Cut Dark Mode Are you sure you want to delete all messages, attachments, and account data from this device and create a new account? @@ -337,6 +363,14 @@ Please wait while the group is created... Failed to Update Group You don’t have permission to delete others’ messages + + Delete Selected Attachment + Delete Selected Attachments + + + Are you sure you want to delete the selected attachment? The message associated with the attachment will also be deleted. + Are you sure you want to delete the selected attachments? The message associated with the attachments will also be deleted. + Are you sure you want to delete {name} from your contacts?\n\nThis will delete your conversation, including all messages and attachments. Future messages from {name} will appear as a message request. Are you sure you want to delete your conversation with {name}?\nThis will permanently delete all messages and attachments. @@ -423,6 +457,8 @@ Your Display Name is visible to users, groups, and communities you interact with. Document Donate + Powerful forces are trying to weaken privacy, but we can’t continue this fight alone.\n\nDonating helps keep {app_name} secure, independent, and online. + {app_name} Needs Your Help Done Download Downloading... @@ -452,22 +488,31 @@ You and {name} reacted with {emoji_name} Reacted to your message {emoji} Enable + Enable Camera Access? Show notifications when you receive new messages. + End Call to Enable Enjoying {app_name}? Needs Work {emoji} It\'s Great {emoji} You\'ve been using {app_name} for a little while, how’s it going? We’d really appreciate hearing your thoughts. Enter Enter the password you set for {app_name} - Enter the password you use to unlock Session &#13; -on startup, not your Recovery Password - Error checking {pro} status. + Enter the password you use to unlock {app_name} on startup, not your Recovery Password + Error checking {pro} status Please check your internet connection and try again. Copy Error and Quit Database Error Something went wrong. Please try again later. - Error loading {pro} plan. + Error loading {pro} access + {app_name} was unable to search for this ONS. Please check your network connection and try again. An unknown error occurred. + This ONS is not registered. Please check it is correct and try again. + Failed to resend invite to {name} in {group_name} + Failed to resend invite to {name} and {count} others in {group_name} + Failed to resend invite to {name} and {other_name} in {group_name} + Failed to resend promotion to {name} in {group_name} + Failed to resend promotion to {name} and {count} others in {group_name} + Failed to resend promotion to {name} and {other_name} in {group_name} Failed to download Failures Feedback @@ -521,6 +566,9 @@ on startup, not your Recovery Password Are you sure you want to leave {group_name}? Are you sure you want to leave {group_name}?\n\nThis will remove all members and delete all group content. Failed to leave {group_name} + {name} was invited to join the group. Chat history from the last 14 days was shared. + {name} and {count} others were invited to join the group. Chat history from the last 14 days was shared. + {name} and {other_name} were invited to join the group. Chat history from the last 14 days was shared. {name} left the group. {name} and {count} others left the group. {name} and {other_name} left the group. @@ -532,6 +580,9 @@ on startup, not your Recovery Password {name} and {other_name} were invited to join the group. You and {count} others were invited to join the group. Chat history was shared. You and {other_name} were invited to join the group. Chat history was shared. + Failed to remove {name} from {group_name} + Failed to remove {name} and {count} others from {group_name} + Failed to remove {name} and {other_name} from {group_name} You left the group. Group Members There are no other members in this group. @@ -545,6 +596,7 @@ on startup, not your Recovery Password You have no messages from {group_name}. Send a message to start the conversation! This group has not been updated in over 30 days. You may experience issues sending messages or viewing group information. You are the only admin in {group_name}.\n\nGroup members and settings cannot be changed without an admin. + You are the only admin in {group_name}.\n\nGroup members and settings cannot be changed without an admin. To leave the group without deleting it, please add a new admin first Pending removal You were promoted to Admin. You and {count} others were promoted to Admin. @@ -594,6 +646,10 @@ on startup, not your Recovery Password Request incognito mode if available. Depending on the keyboard you are using, your keyboard may ignore this request. Info Invalid shortcut + + Invite Contact + Invite Contacts + Invite Failed Invites Failed @@ -602,6 +658,12 @@ on startup, not your Recovery Password The invite could not be sent. Would you like to try again? The invites could not be sent. Would you like to try again? + + Invite Member + Invite Members + + Invite a new member to the group by entering your friend\'s Account ID, ONS or scanning their QR code {icon} + Invite a new member to the group by entering your friend\'s Account ID, ONS or scanning their QR code Join Later Launch {app_name} automatically when your computer starts up. @@ -645,10 +707,16 @@ on startup, not your Recovery Password Tap to unlock {app_name} is unlocked Logs + Manage Admins Manage Members Manage {pro} Max + Maybe Later Media + + %1$d Member Selected + %1$d Members Selected + %1$d member %1$d members @@ -658,7 +726,9 @@ on startup, not your Recovery Password %1$d active members Add Account ID or ONS + Members can only be promoted once they\'ve accepted an invite to join the group. Invite Contacts + You don’t have any contacts to invite to this group.\nGo back and invite members using their Account ID or ONS. Send Invite Send Invites @@ -667,8 +737,10 @@ on startup, not your Recovery Password Would you like to share group message history with {name} and {count} others? Would you like to share group message history with {name} and {other_name}? Share message history + Share message history from last 14 days Share new messages only Invite + Members (Non-Admins) Menu Bar Message Read more @@ -687,6 +759,7 @@ on startup, not your Recovery Password Start a new conversation by entering your friend\'s Account ID or ONS. Start a new conversation by entering your friend\'s Account ID, ONS or scanning their QR code. + Start a new conversation by entering your friend\'s Account ID, ONS or scanning their QR code {icon} You\'ve got a new message. You\'ve got %1$d new messages. @@ -744,13 +817,17 @@ on startup, not your Recovery Password Remove Nickname Set Nickname No + There are no non-admin members in this group. No Suggestions + Send messages up to 10,000 characters in all conversations. + Organize chats with unlimited pinned conversations. None Not now Note to Self You have no messages in Note to Self. Hide Note to Self Are you sure you want to hide Note to Self? + PLEASE NOTE: By {action_type}, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon} Notification Display Display the sender\'s name and a preview of the message content. Display only the sender\'s name without any message content. @@ -795,7 +872,11 @@ on startup, not your Recovery Password Okay On On your {device_type} device - Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, change your plan via the {app_pro} settings. + Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, cancel {pro} via the {app_pro} settings. + Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, update your {pro} access via the {app_pro} settings. + On a linked device + On the {platform_store} website + On the {platform} website Create account Account Created I have an account @@ -820,7 +901,9 @@ on startup, not your Recovery Password We couldn\'t recognize this ONS. Please check it and try again. We were unable to search for this ONS. Please try again later. Open - Open {platform_store} Website + Open {platform_store} Website + Open {platform} Website + Open Settings Open Survey Other Password @@ -854,6 +937,8 @@ on startup, not your Recovery Password Setting a strong password helps protect your messages and attachments if your device is ever lost or stolen. Passwords Paste + Payment Error + Your payment has been processed successfully, but there was an error {action_type} your {pro} status.\n\nPlease check your network connection and retry. Permission Change {app_name} needs music and audio access in order to send files, music and audio, but it has been permanently denied. Tap Settings → Permissions, and turn \"Music and audio\" on. {app_name} needs to use Apple Music to play media attachments. @@ -897,12 +982,34 @@ on startup, not your Recovery Password Preferences Preview Preview Notification + Your {pro} access is active!\n\nYour {pro} access will automatically renew for another {current_plan_length} on {date}. + Your {pro} access will expire on {date}.\n\nUpdate your {pro} access now to ensure you automatically renew before your {pro} access expires. + Your {pro} access is active!\n\nYour {pro} access will automatically renew for another\n{current_plan_length} on {date}. Any updates you make here will take effect at your next renewal. + {pro} Access Error + Your {pro} access will expire on {date}. + {pro} Access Loading + Your {pro} access information is still being loaded. You cannot update until this process is complete. + {pro} access loading... + Unable to connect to the network to load your {pro} access information. Updating {pro} via {app_name} will be disabled until connectivity is restored.\n\nPlease check your network connection and retry. + {pro} Access Not Found + {app_name} detected that your account does not have {pro} access. If you believe this is a mistake, please reach out to {app_name} support for assistance. + Recover {pro} Access + Renew {pro} Access + Currently, {pro} access can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you are using {app_name} Desktop, you\'re not able to renew here.\n\n{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} + Renew your {pro} access on the {platform_store} website using the {platform_account} you signed up for {pro} with. + Renew on the {platform} website using the {platform_account} you signed up for {pro} with. + Renew your {pro} access to start using powerful {app_pro} Beta features again. + {pro} Access Recovered + {app_name} detected and recovered {pro} access for your account. Your {pro} status has been restored! + Because you originally signed up for {app_pro} via the {platform_store}, you\'ll need to use your {platform_account} to update your {pro} access. + Currently, {pro} access can only be purchased via the {platform_store} or {platform_store_other}. Because you are using {app_name} Desktop, you\'re not able to upgrade to {pro} here.\n\n{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} Activated + activating You\'re all set! - Your {app_pro} plan was updated! You will be billed when your current {pro} plan is automatically renewed on {date}. + Your {app_pro} access was updated! You will be billed when {pro} is automatically renewed on {date}. You’ve already got Go ahead and upload GIFs and animated WebP images for your display picture! - Get animated display pictures and unlock premium features with {app_pro} + Get animated display pictures and unlock premium features with {app_pro} Beta Animated Display Picture users can upload GIFs Animated Display Pictures @@ -921,21 +1028,23 @@ on startup, not your Recovery Password {price} Billed Annually {price} Billed Monthly {price} Billed Quarterly - Want to send longer messages? Send more text and unlock premium features with {app_pro} - Want more pins? Organize your chats and unlock premium features with {app_pro} - Want more than 5 pins? Organize your chats and unlock premium features with {app_pro} + Want to send longer messages?\nSend more text and unlock premium features with {app_pro} Beta + Want more pins?\nOrganize your chats and unlock premium features with {app_pro} Beta + Want more than {limit} pins?\nOrganize your chats and unlock premium features with {app_pro} Beta + Sorry to see you cancel {pro}. Here\'s what you need to know before canceling your {pro} access. Cancellation - Canceling your {app_pro} plan will prevent your plan from automatically renewing before your {pro} plan expires. Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your plan expires.\n\n -Because you originally signed up for {app_pro} via a {platform_account}, you\'ll need to use the same {platform_account} to cancel your plan. - Two ways to cancel your plan: - Are you sure you want to delete your data from this device?\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} plan later. - Are you sure you want to delete your data from the network? If you continue, you will not be able to restore your messages or contacts.\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} plan later. - Your current plan is already discounted by {percent}% of the full {app_pro} price. + Canceling {pro} access will prevent automatic renewal from occurring before {pro} access expires. Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your {pro} access expires.\n\nBecause you originally signed up for {app_pro} using your {platform_account}, you\'ll need to use the same {platform_account} to cancel {pro}. + Two ways to cancel your {pro} access: + Canceling {pro} access will prevent automatic renewal from occurring before {pro} expires.\n\nCanceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your {pro} access expires. + Choose the {pro} access option that\'s right for you.\nLonger access means bigger discounts. + Are you sure you want to delete your data from this device?\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} access later. + Are you sure you want to delete your data from the network? If you continue, you will not be able to restore your messages or contacts.\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} access later. + Your {pro} access is already discounted by {percent}% of the full {app_pro} price. Error refreshing {pro} status Expired - Unfortunately, your {pro} plan has expired. Renew to keep accessing the exclusive perks and features of {app_pro}. + Unfortunately, your {pro} access has expired.\nRenew to reactivate the exclusive perks and features of {app_pro} Beta. Expiring Soon - Your {pro} plan is expiring in {time}. Update your plan to keep accessing the exclusive perks and features of {app_pro}. + Your {pro} access is expiring in {time}.\nUpdate now to keep accessing the exclusive perks and features of {app_pro} Beta {pro} expiring in {time} {pro} FAQ Find answers to common questions in the {app_pro} FAQ. @@ -944,13 +1053,14 @@ Because you originally signed up for {app_pro} via a {platform_account}, Plus loads more exclusive features Messages up to 10,000 characters Pin unlimited conversations + Want to use {app_name} to its fullest potential?\nUpgrade to {app_pro} Beta to get access to loads of exclusive perks and features. Group Activated This group has expanded capacity! It can support up to 300 members because a group admin has %1$s Group Upgraded %1$s Groups Upgraded - Requesting a refund is final. If approved, your {pro} plan will be canceled immediately and you will lose access to all {pro} features. + Requesting a refund is final. If approved, your {pro} access will be canceled immediately and you will lose access to all {pro} features. Increased Attachment Size Increased Message Length Larger Groups @@ -963,50 +1073,46 @@ Because you originally signed up for {app_pro} via a {platform_account}, %1$s Longer Messages Sent This message used the following {app_pro} features: - Currently, there are three ways to renew: + With a new installation + Reinstall {app_name} on this device via the {platform_store}, restore your account with your Recovery Password, and renew {pro} from the {app_pro} settings. + Reinstall {app_name} on this device via the {platform_store}, restore your account with your Recovery Password, and upgrade to {pro} from the {app_pro} settings. + For now, there are three ways to renew: + For now, there are two ways to renew: {percent}% Off %1$s Pinned Conversation %1$s Pinned Conversations - Your {app_pro} plan is active!\n\nYour plan will automatically renew for another {current_plan} on {date}. Updates to your plan take effect when {pro} is next renewed. - Your {app_pro} plan is active!\n\nYour plan will automatically renew for another {current_plan} on {date}. - Your {app_pro} plan will expire on {date}.\n\nUpdate your plan now to ensure uninterrupted access to exclusive Pro features. - {pro} Plan Error - Your {app_pro} plan will expire on {date}. - {pro} Plan Loading - Information about your {pro} plan is still being loaded. You cannot update your plan until this process is complete. - {pro} plan loading... - Unable to connect to the network to load your current plan. Updating your plan via {app_name} will be disabled until connectivity is restored.\n\nPlease check your network connection and retry. - {pro} Plan Not Found - No active plan was found for your account. If you believe this is a mistake, please reach out to {app_name} support for assistance. - Because you originally signed up for {app_pro} via the {platform_store} Store, you\'ll need to use the same {platform_account} to request a refund. - Because you originally signed up for {app_pro} via the {platform_store} Store, your refund request will be processed by {app_name} Support.\n\nRequest a refund by hitting the button below and completing the refund request form.\n\nWhile {app_name} Support strives to process refund requests within 24-72 hours, processing may take longer during times of high request volume. - Recover {pro} Plan - Renew {pro} Plan - Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store} Stores. Because you are using {app_name} Desktop, you\'re not able to renew your plan here.\n\n{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store} Stores. {pro} Roadmap {icon} - Renew your plan in the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store} Store. - Renew your plan on the {platform_store} website using the {platform_account} you signed up for {pro} with. - Renew your {app_pro} plan to start using powerful {app_pro} Beta features again. - Your {app_pro} plan has been renewed! Thank you for supporting the {network_name}. - {pro} Plan Restored - A valid plan for {app_pro} was detected and your {pro} status has been restored! - Because you originally signed up for {app_pro} via the {platform_store} Store, you\'ll need to use your {platform_account} to update your plan. + Because you originally signed up for {app_pro} via the {platform_store}, you\'ll need to use your {platform_account} to request a refund. + Because you originally signed up for {app_pro} via the {platform_store}, your refund request will be processed by {app_name} Support.\n\nRequest a refund by hitting the button below and completing the refund request form.\n\nWhile {app_name} Support strives to process refund requests within 24-72 hours, processing may take longer during times of high request volume. + Your {app_pro} access has been renewed! Thank you for supporting the {network_name}. 1 Month - {monthly_price} / Month 3 Months - {monthly_price} / Month 12 Months - {monthly_price} / Month + re-activating + Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, request a refund via the {app_pro} settings. We’re sorry to see you go. Here\'s what you need to know before requesting a refund. - {platform_account} is now processing your refund request. This typically takes 24-48 hours. Depending on their decision, you may see your {pro} status change in {app_name}. + {platform} is now processing your refund request. This typically takes 24-48 hours. Depending on their decision, you may see your {pro} status change in {app_name}. Your refund request will be handled by {app_name} Support.\n\nRequest a refund by hitting the button below and completing the refund request form.\n\nWhile {app_name} Support strives to process refund requests within 24-72 hours, processing may take longer during times of high request volume. - Your refund request will be handled exclusively by {platform_account} through the {platform_account} website.\n\nDue to {platform_account} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued. - Please contact {platform_account} for further updates on your refund request. Due to {platform_account} refund policies, {app_name} developers have no ability to influence the outcome of refund requests.\n\n{platform_store} Refund Support + Your refund request will be handled exclusively by {platform} through the {platform} website.\n\nDue to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued. + Please contact {platform} for further updates on your refund request. Due to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests.\n\n{platform} Refund Support Refunding {pro} - Refunds for {app_pro} plans are handled exclusively by {platform_account} through the {platform_store} Store.\n\nDue to {platform_account} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued. + Refunds for {app_pro} are handled exclusively by {platform} through the {platform_store}.\n\nDue to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued. + Want to use animated display pictures again?\nRenew your {pro} access to unlock the features you’ve been missing out on. Renew {pro} Beta - Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store} Stores. Because you installed {app_name} using the {buildVariant}, you\'re not able to renew your plan here.\n\n{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store} Stores. {pro} Roadmap {icon} + Renew your {pro} access from the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store_other}. + Want to send longer messages again?\nRenew your {pro} access to unlock the features you’ve been missing out on. + Want to use {app_name} to its max potential again?\nRenew your {pro} access to unlock the features you’ve been missing out on. + Want to pin more than {limit} conversations again?\nRenew your {pro} access to unlock the features you’ve been missing out on. + Want to pin more conversations again?\nRenew your {pro} access to unlock the features you’ve been missing out on. + By renewing, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon} + Pro renewal unsuccessful, retrying soon + renewing + Currently, {pro} access can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you\'re not able to renew here.\n\n{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} Refund Requested Send more with {pro} Settings + Start Using {pro} Your {pro} Stats {pro} Stats Loading Your {pro} stats are loading, please wait. @@ -1016,16 +1122,29 @@ Because you originally signed up for {app_pro} via a {platform_account}, {pro} Status Loading Your {pro} information is being loaded. Some actions on this page may be unavailable until loading is complete. {pro} status loading + Unable to connect to the network to check your {pro} status. You cannot continue until connectivity is restored.\n\nPlease check your network connection and retry. Unable to connect to the network to check your {pro} status. You cannot upgrade to {pro} until connectivity is restored.\n\nPlease check your network connection and retry. Unable to connect to the network to refresh your {pro} status. Some actions on this page will be disabled until connectivity is restored.\n\nPlease check your network connection and retry. - Need help with your {pro} plan? Submit a request to the support team. + Unable to connect to the network to load your current {pro} access. Renewing {pro} via {app_name} will be disabled until connectivity is restored.\n\nPlease check your network connection and retry. + Need help with {pro}? Submit a request to the support team. + By {action_type}, you are {activation_type} {app_pro} via the {app_name} Protocol. {entity} will facilitate that activation but is not the provider of {app_pro}. {entity} is not responsible for the performance, availability, or functionality of {app_pro}. By updating, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon} Unlimited Pins Organize all your chats with unlimited pinned conversations. - You are currently on the {current_plan} Plan. Are you sure you want to switch to the {selected_plan} Plan?\n\nBy updating, your plan will automatically renew on {date} for an additional {selected_plan} of {pro} access. - Your plan will expire on {date}.\n\nBy updating, your plan will automatically renew on {date} for an additional {selected_plan} of Pro access. - Want to get more out of {app_name}? Upgrade to {app_pro} for a more powerful messaging experience. - {platform_account} is processing your refund request + Your current billing option grants {current_plan_length} of {pro} access. Are you sure you want to switch to the {selected_plan_length_singular} billing option?\n\nBy updating, your {pro} access will automatically renew on {date} for an additional {selected_plan_length} of {pro} access. + Your {pro} access will expire on {date}.\n\nBy updating, your {pro} access will automatically renew on {date} for an additional {selected_plan_length} of {pro} access. + updating + Upgrade to {app_pro} Beta to get access to loads of exclusive perks and features. + Upgrade to {pro} from the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store_other}. + Currently, {pro} access can only be purchased via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you\'re not able to upgrade to {pro} here.\n\n{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} + For now, there is only one way to upgrade: + For now, there are two ways to upgrade: + You have upgraded to {app_pro}!\nThank you for supporting the {network_name}. + upgrading + Upgrading to {pro} + By upgrading, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon} + Want to get more out of {app_name}?\nUpgrade to {app_pro} Beta for a more powerful messaging experience. + {platform} is processing your refund request Profile Display Picture Failed to remove display picture. @@ -1033,6 +1152,11 @@ Because you originally signed up for {app_pro} via a {platform_account}, Please pick a smaller file. Failed to update profile. Promote + Admins will be able to see the last 14 days of message history and cannot be demoted or removed from the group. + + Promote Member + Promote Members + Promotion Failed Promotions Failed @@ -1081,20 +1205,51 @@ Because you originally signed up for {app_pro} via a {platform_account}, This is your recovery password. If you send it to someone they\'ll have full access to your account. Recreate Group Redo - Because you originally signed up for {app_pro} via a different {platform_account}, you\'ll need to use that {platform_account} to update your plan. + Because you originally signed up for {app_pro} via a different {platform_account}, you\'ll need to use that {platform_account} to update your {pro} access. + Two ways to request a refund: Reduce message length by {count} %1$d character remaining %1$d characters remaining + Remind Me Later Remove + + Remove Member + Remove Members + + + Remove member and their messages + Remove members and their messages + Failed to remove password Remove your current password for {app_name}. Locally stored data will be re-encrypted with a randomly generated key, stored on your device. + + Removing member + Removing members + Renew - Renewing Pro + Renewing {pro} Reply Request Refund + Request a refund on the {platform} website, using the {platform_account} you signed up for {pro} with. Resend + + Resend Invite + Resend Invites + + + Resend Promotion + Resend Promotions + + + Resending invite + Resending invites + + + Resending promotion + Resending promotions + Loading country information... Restart Resync @@ -1131,6 +1286,10 @@ Because you originally signed up for {app_pro} via a {platform_account}, Sending Sending Call Offer Sending Connection Candidates + + Sending Promotion + Sending Promotions + Sent: Appearance Clear Data @@ -1166,6 +1325,7 @@ Because you originally signed up for {app_pro} via a {platform_account}, Share with your friends wherever you usually speak with them — then move the conversation here. There is an issue opening the database. Please restart the app and try again. Oops! Looks like you don\'t have a {app_name} account yet.\n\nYou\'ll need to create one in the {app_name} app before you can share. + Would you like to share group message history with this user? Share to {app_name} Show Show All @@ -1195,6 +1355,9 @@ Because you originally signed up for {app_pro} via a {platform_account}, Undo Unknown Unsupported CPU + Update + Update {pro} Access + Two ways to update your {pro} access: App updates Update Community Information Community name and description are visible to all community members @@ -1209,8 +1372,6 @@ Because you originally signed up for {app_pro} via a {platform_account}, Please enter a shorter group description A new version of {app_name} is available, tap to update A new version ({version}) of {app_name} is available. - Update Plan - Two ways to update your plan: Update Profile Information Your display name and display picture are visible in all conversations. Go to Release Notes @@ -1219,6 +1380,7 @@ Because you originally signed up for {app_pro} via a {platform_account}, Last updated {relative_time} ago Updates Updating... + Upgrade Upgrade {app_name} Upgrade to Uploading @@ -1228,8 +1390,9 @@ Because you originally signed up for {app_pro} via a {platform_account}, Are you sure you want to open this URL in your browser?\n\n{url} Links will open in your browser. Use Fast Mode - Via the {platform_store} website - Change your plan using the {platform_account} you used to sign up with, via the {platform_store} website. + Change your plan using the {platform_account} you used to sign up with, via the {platform} website . + Via the {platform} website + Update your {pro} access using the {platform_account} you used to sign up with, via the {platform_store} website. Video Unable to play video. View @@ -1238,6 +1401,7 @@ Because you originally signed up for {app_pro} via a {platform_account}, This can take a few minutes. One moment please... Warning + Support for iOS 15 has ended. Update to iOS 16 or later to continue receiving app updates. Window Yes You diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 4517f8262c..c73762f95f 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -16,8 +16,6 @@ @color/gray50 ?colorPrimary @style/Theme.Session.BottomSheet - false - 0 @dimen/dialog_corner_radius @style/ThemeOverlay.Session.AlertDialog @style/ThemeOverlay.Session.AlertDialog diff --git a/app/src/main/res/xml/preferences_privacy.xml b/app/src/main/res/xml/preferences_privacy.xml index ab5449e31d..eaed62b9b1 100644 --- a/app/src/main/res/xml/preferences_privacy.xml +++ b/app/src/main/res/xml/preferences_privacy.xml @@ -36,13 +36,11 @@ - - - diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 4b39a5bd6f..e1d0e447d6 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -1,41 +1,94 @@ package org.thoughtcrime.securesms.pro.subscription import android.app.Application -import android.widget.Toast import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingResult import com.android.billingclient.api.PendingPurchasesParams +import com.android.billingclient.api.ProductDetailsResult +import com.android.billingclient.api.Purchase import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryPurchasesParams import com.android.billingclient.api.queryProductDetails +import com.android.billingclient.api.queryPurchasesAsync +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.util.CurrentActivityObserver +import java.time.Instant +import java.time.temporal.ChronoUnit import javax.inject.Inject +import javax.inject.Singleton /** * The Google Play Store implementation of our subscription manager */ +@Singleton class PlayStoreSubscriptionManager @Inject constructor( private val application: Application, - @param:ManagerScope private val scope: CoroutineScope, private val currentActivityObserver: CurrentActivityObserver, -) : SubscriptionManager { + private val prefs: TextSecurePreferences, + proStatusManager: ProStatusManager, + @param:ManagerScope scope: CoroutineScope, +) : SubscriptionManager(proStatusManager, scope) { override val id = "google_play_store" - override val displayName = "" + override val name = "Google Play Store" override val description = "" override val iconRes = null + // specifically test the google play billing + private val _playBillingAvailable = MutableStateFlow(false) + + // generic billing support method. Uses the property above and also checks the debug pref + override val supportsBilling: StateFlow = combine( + _playBillingAvailable, + (TextSecurePreferences.events.filter { it == TextSecurePreferences.DEBUG_FORCE_NO_BILLING } as Flow<*>) + .onStart { emit(Unit) } + .map { prefs.getDebugForceNoBilling() }, + ){ available, forceNoBilling -> + !forceNoBilling && available + } + .stateIn(scope, SharingStarted.Eagerly, false) + private val billingClient by lazy { BillingClient.newBuilder(application) .setListener { result, purchases -> - Log.d(TAG, "onPurchasesUpdated: $result, $purchases") + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "Billing callback. Result: $result, Purchases: ${purchases?.map { it.orderId }}") + + if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { + purchases.firstOrNull()?.let{ + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, + "Billing callback. We have a purchase [${it.orderId}]. Acknowledged? ${it.isAcknowledged}") + + onPurchaseSuccessful( + orderId = it.orderId ?: "", + paymentId = it.purchaseToken + ) + } + } else { + Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "Purchase failed or cancelled: $result") + scope.launch { + _purchaseEvents.emit(PurchaseEvent.Cancelled) + } + } } .enableAutoServiceReconnection() .enablePendingPurchases( @@ -50,91 +103,209 @@ class PlayStoreSubscriptionManager @Inject constructor( override val availablePlans: List = ProSubscriptionDuration.entries.toList() - override fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) { - scope.launch { - try { - val activity = checkNotNull(currentActivityObserver.currentActivity.value) { - "No current activity available to launch the billing flow" - } + override suspend fun purchasePlan(subscriptionDuration: ProSubscriptionDuration): Result { + try { + val activity = checkNotNull(currentActivityObserver.currentActivity.value) { + "No current activity available to launch the billing flow" + } - val result = billingClient.queryProductDetails( - QueryProductDetailsParams.newBuilder() - .setProductList( - listOf( - QueryProductDetailsParams.Product.newBuilder() - .setProductId("session_pro") - .setProductType(BillingClient.ProductType.SUBS) - .build() - ) - ) - .build() - ) + val result = getProductDetails() - check(result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - "Failed to query product details. Reason: ${result.billingResult}" - } + check(result?.billingResult?.responseCode == BillingClient.BillingResponseCode.OK) { + "Failed to query product details. Reason: ${result?.billingResult}" + } - val productDetails = checkNotNull(result.productDetailsList?.firstOrNull()) { - "Unable to get the product: product for given id is null" + val productDetails = checkNotNull(result.productDetailsList?.firstOrNull()) { + "Unable to get the product: product for given id is null" + } + + val planId = subscriptionDuration.id + + val offerDetails = checkNotNull(productDetails.subscriptionOfferDetails + ?.firstOrNull { it.basePlanId == planId }) { + "Unable to find a plan with id $planId" } - val planId = subscriptionDuration.planId + // Check for existing subscription + val existingPurchase = getExistingSubscription() - val offerDetails = checkNotNull(productDetails.subscriptionOfferDetails - ?.firstOrNull { it.basePlanId == planId }) { - "Unable to find a plan with id $planId" - } + val billingFlowParamsBuilder = BillingFlowParams.newBuilder() + .setProductDetailsParamsList( + listOf( + BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .setOfferToken(offerDetails.offerToken) + .build() + ) + ) - val billingResult = billingClient.launchBillingFlow( - activity, BillingFlowParams.newBuilder() - .setProductDetailsParamsList( - listOf( - BillingFlowParams.ProductDetailsParams.newBuilder() - .setProductDetails(productDetails) - .setOfferToken(offerDetails.offerToken) - .build() - ) + // If user has an existing subscription, configure upgrade/downgrade + if (existingPurchase != null) { + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "Found existing subscription, configuring upgrade/downgrade with WITHOUT_PRORATION") + + billingFlowParamsBuilder.setSubscriptionUpdateParams( + BillingFlowParams.SubscriptionUpdateParams.newBuilder() + .setOldPurchaseToken(existingPurchase.purchaseToken) + // WITHOUT_PRORATION ensures new plan only bills when existing plan expires/renews + // This applies whether the subscription is auto-renewing or canceled + .setSubscriptionReplacementMode( + BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITHOUT_PRORATION ) .build() ) + } - check(billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - "Unable to launch the billing flow. Reason: ${billingResult.debugMessage}" - } - - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.e(TAG, "Error purchase plan", e) + val billingResult = billingClient.launchBillingFlow( + activity, + billingFlowParamsBuilder.build() + ) - withContext(Dispatchers.Main) { - Toast.makeText(application, e.message, Toast.LENGTH_LONG).show() - } + check(billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + "Unable to launch the billing flow. Reason: ${billingResult.debugMessage}" } + + return Result.success(Unit) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(DebugLogGroup.PRO_SUBSCRIPTION.label, "Error purchase plan", e) + + // pass the purchase error information to subscribers + _purchaseEvents.emit(PurchaseEvent.Failed.GenericError()) + + return Result.failure(e) } } - private val ProSubscriptionDuration.planId: String - get() = when (this) { - ProSubscriptionDuration.ONE_MONTH -> "session-pro-1-month" - ProSubscriptionDuration.THREE_MONTHS -> "session-pro-3-months" - ProSubscriptionDuration.TWELVE_MONTHS -> "session-pro-12-months" - } + private suspend fun getProductDetails(): ProductDetailsResult? { + if(!billingClient.isReady || !_playBillingAvailable.value) return null + + return billingClient.queryProductDetails( + QueryProductDetailsParams.newBuilder() + .setProductList( + listOf( + QueryProductDetailsParams.Product.newBuilder() + .setProductId("session_pro") + .setProductType(BillingClient.ProductType.SUBS) + .build() + ) + ) + .build() + ) + } override fun onPostAppStarted() { super.onPostAppStarted() + if (!hasPlayServices() || !hasPlayStore()) { + _playBillingAvailable.update { false } + Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "Play Billing unavailable (GMS/Play Store missing).") + return + } + billingClient.startConnection(object : BillingClientStateListener { override fun onBillingServiceDisconnected() { - Log.w(TAG, "onBillingServiceDisconnected") + + _playBillingAvailable.update { false } } override fun onBillingSetupFinished(result: BillingResult) { - Log.d(TAG, "onBillingSetupFinished with $result") + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "onBillingSetupFinished with $result") + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + _playBillingAvailable.update { true } + } else { + _playBillingAvailable.update { false } + runCatching { billingClient.endConnection() } + } } }) } + private fun hasPlayServices(): Boolean { + val gms = GoogleApiAvailability.getInstance() + return gms.isGooglePlayServicesAvailable(application) == ConnectionResult.SUCCESS + } + + private fun hasPlayStore(): Boolean { + return try { + val ai = application.packageManager.getApplicationInfo("com.android.vending", 0) + ai.enabled + } catch (_: Exception) { + false + } + } + + /** + * Gets the user's existing active subscription if one exists. + * Returns null if no active subscription is found. + */ + private suspend fun getExistingSubscription(): Purchase? { + if(!billingClient.isReady || !_playBillingAvailable.value) return null + + return try { + val params = QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.SUBS) + .build() + + val result = billingClient.queryPurchasesAsync(params) + + // Return the first active subscription + result.purchasesList.firstOrNull { + it.purchaseState == Purchase.PurchaseState.PURCHASED + } + } catch (e: Exception) { + Log.e(DebugLogGroup.PRO_SUBSCRIPTION.label, "Error querying existing subscription", e) + null + } + } + + override suspend fun hasValidSubscription(): Boolean { + // if in debug mode, always return true + return if(prefs.forceCurrentUserAsPro()) true + else getExistingSubscription() != null + } + + @Throws(Exception::class) + override suspend fun getSubscriptionPrices(): List { + val result = getProductDetails() + check(result?.billingResult?.responseCode == BillingClient.BillingResponseCode.OK) { + "Failed to query product details. Reason: ${result?.billingResult}" + } + + val productDetails = result.productDetailsList?.firstOrNull() + ?: run { + Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "No ProductDetails returned for product id session_pro") + return emptyList() + } + + val offersByBasePlan = productDetails.subscriptionOfferDetails + ?.associateBy { it.basePlanId } + .orEmpty() + + // For each duration we support, find the matching offer by basePlanId + return availablePlans.mapNotNull { duration -> + val offer = offersByBasePlan[duration.id] + if (offer == null) { + Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "No offer found for basePlanId=${duration.id}") + return@mapNotNull null + } + + val phases = offer.pricingPhases.pricingPhaseList + + val pricing = phases.firstOrNull { + it.recurrenceMode == com.android.billingclient.api.ProductDetails.RecurrenceMode.INFINITE_RECURRING + } ?:return@mapNotNull null // skip if not found + + SubscriptionManager.SubscriptionPricing( + subscriptionDuration = duration, + priceAmountMicros = pricing.priceAmountMicros, + priceCurrencyCode = pricing.priceCurrencyCode, + billingPeriodIso = pricing.billingPeriod, // e.g., P1M, P3M, P1Y + formattedTotal = pricing.formattedPrice // Play-formatted localized total + ) + } + } + companion object { private const val TAG = "PlayStoreSubscriptionManager" } diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/reviews/PlayStoreReviewManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/reviews/PlayStoreReviewManager.kt index a28a203158..41d5f79e06 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/reviews/PlayStoreReviewManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/reviews/PlayStoreReviewManager.kt @@ -38,7 +38,7 @@ class PlayStoreReviewManager @Inject constructor( manager.launchReview(activity, info) val hasLaunchedSomething = withTimeoutOrNull(500.milliseconds) { - currentActivityObserver.currentActivity.first { it != requestedOnActivity } + currentActivityObserver.currentActivity.first { it != null && it != requestedOnActivity } } != null require(hasLaunchedSomething) { diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt index c757b93507..1c6b3b18df 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt @@ -17,7 +17,6 @@ import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.session.libsession.utilities.Address.Companion.toAddress -import org.session.libsession.utilities.recipients.ProStatus import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientData import org.thoughtcrime.securesms.BaseViewModelTest @@ -51,7 +50,7 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { fun `note to self, off, new config`() = runTest { val viewModel = createViewModel(Recipient( address = STANDARD_ADDRESS, - data = RecipientData.Self(name = "Myself", avatar = null, expiryMode = ExpiryMode.NONE, priority = 1, proStatus = ProStatus.None, profileUpdatedAt = null), + data = RecipientData.Self(name = "Myself", avatar = null, expiryMode = ExpiryMode.NONE, priority = 1, proData = null, profileUpdatedAt = null), )) advanceUntilIdle() @@ -94,25 +93,23 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { val recipient = Recipient( address = GROUP_ADDRESS, data = RecipientData.Group( - partial = RecipientData.PartialGroup( + name = "Group Name", + avatar = null, + expiryMode = ExpiryMode.NONE, + groupInfo = GroupInfo.ClosedGroupInfo( + groupAccountId = GROUP_ADDRESS.address, + adminKey = Bytes(ByteArray(32)), + authData = Bytes(ByteArray(32)), + priority = 1, + invited = false, name = "Group Name", - avatar = null, - expiryMode = ExpiryMode.NONE, - groupInfo = GroupInfo.ClosedGroupInfo( - groupAccountId = GROUP_ADDRESS.address, - adminKey = Bytes(ByteArray(32)), - authData = Bytes(ByteArray(32)), - priority = 1, - invited = false, - name = "Group Name", - kicked = false, - destroyed = false, - joinedAtSecs = System.currentTimeMillis() / 1000L, - ), - proStatus = ProStatus.None, - members = listOf(), - description = null, + kicked = false, + destroyed = false, + joinedAtSecs = System.currentTimeMillis() / 1000L, ), + proData = null, + members = listOf(), + description = null, firstMember = Recipient( address = STANDARD_ADDRESS, data = RecipientData.Self( @@ -120,7 +117,7 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { avatar = null, expiryMode = ExpiryMode.NONE, priority = 1, - proStatus = ProStatus.None, + proData = null, profileUpdatedAt = null ) ), @@ -171,25 +168,23 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { val recipient = Recipient( address = GROUP_ADDRESS, data = RecipientData.Group( - partial = RecipientData.PartialGroup( + name = "Group Name", + avatar = null, + expiryMode = ExpiryMode.NONE, + groupInfo = GroupInfo.ClosedGroupInfo( + groupAccountId = GROUP_ADDRESS.address, + adminKey = null, + authData = Bytes(ByteArray(32)), + priority = 1, + invited = false, name = "Group Name", - avatar = null, - expiryMode = ExpiryMode.NONE, - groupInfo = GroupInfo.ClosedGroupInfo( - groupAccountId = GROUP_ADDRESS.address, - adminKey = null, - authData = Bytes(ByteArray(32)), - priority = 1, - invited = false, - name = "Group Name", - kicked = false, - destroyed = false, - joinedAtSecs = System.currentTimeMillis() / 1000L, - ), - proStatus = ProStatus.None, - members = listOf(), - description = null, + kicked = false, + destroyed = false, + joinedAtSecs = System.currentTimeMillis() / 1000L, ), + proData = null, + members = listOf(), + description = null, firstMember = Recipient( address = STANDARD_ADDRESS, data = RecipientData.Self( @@ -197,7 +192,7 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { avatar = null, expiryMode = ExpiryMode.NONE, priority = 1, - proStatus = ProStatus.None, + proData = null, profileUpdatedAt = null ) ), @@ -257,7 +252,7 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { blocked = false, expiryMode = ExpiryMode.NONE, priority = 1, - proStatus = ProStatus.None, + proData = null, profileUpdatedAt = null ) ) @@ -311,7 +306,7 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { blocked = false, expiryMode = ExpiryMode.AfterSend(time.inWholeSeconds), priority = 1, - proStatus = ProStatus.None, + proData = null, profileUpdatedAt = null ) ) @@ -372,7 +367,7 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { blocked = false, expiryMode = ExpiryMode.AfterSend(time.inWholeSeconds), priority = 1, - proStatus = ProStatus.None, + proData = null, profileUpdatedAt = null ) ) @@ -433,7 +428,7 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { blocked = false, expiryMode = ExpiryMode.AfterRead(time.inWholeSeconds), priority = 1, - proStatus = ProStatus.None, + proData = null, profileUpdatedAt = null ) ) @@ -496,7 +491,7 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { blocked = false, expiryMode = ExpiryMode.AfterRead(time.inWholeSeconds), priority = 1, - proStatus = ProStatus.None, + proData = null, profileUpdatedAt = null ) ) diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt index 33594e5d3d..a93273784d 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt @@ -25,10 +25,8 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager -import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress -import org.session.libsession.utilities.recipients.ProStatus import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientData import org.thoughtcrime.securesms.BaseViewModelTest @@ -74,7 +72,7 @@ class ConversationViewModelTest : BaseViewModelTest() { blocked = false, expiryMode = ExpiryMode.NONE, 1, - proStatus = ProStatus.None, + proData = null, profileUpdatedAt = null ) ) diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionViewModelTest.kt index 7795cf7a0c..ffc4c63815 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionViewModelTest.kt @@ -7,7 +7,7 @@ import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest -import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.util.ExpiryMode import org.junit.Before import org.junit.Rule @@ -20,11 +20,9 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.robolectric.RobolectricTestRunner import org.session.libsession.messaging.open_groups.GroupMemberRole -import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress -import org.session.libsession.utilities.recipients.ProStatus import org.session.libsession.utilities.recipients.RecipientData import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.truncateIdForDisplay @@ -131,7 +129,7 @@ class MentionViewModelTest : BaseViewModelTest() { avatar = null, expiryMode = ExpiryMode.NONE, priority = 0, - proStatus = ProStatus.None, + proData = null, profileUpdatedAt = null, ) ) diff --git a/app/src/test/java/org/thoughtcrime/securesms/reviews/ui/InAppReviewViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/reviews/ui/InAppReviewViewModelTest.kt index ea2454d747..8f7f201f99 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/reviews/ui/InAppReviewViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/reviews/ui/InAppReviewViewModelTest.kt @@ -43,6 +43,7 @@ class InAppReviewViewModelTest : BaseViewModelTest() { val vm = InAppReviewViewModel( manager = manager, storeReviewManager = storeReviewManager, + prefs = mock() ) vm.uiState.test { @@ -76,6 +77,7 @@ class InAppReviewViewModelTest : BaseViewModelTest() { val vm = InAppReviewViewModel( manager = manager, storeReviewManager = storeReviewManager, + prefs = mock() ) vm.uiState.test { @@ -113,6 +115,7 @@ class InAppReviewViewModelTest : BaseViewModelTest() { val vm = InAppReviewViewModel( manager = manager, storeReviewManager = storeReviewManager, + prefs = mock() ) vm.uiState.test { @@ -148,6 +151,7 @@ class InAppReviewViewModelTest : BaseViewModelTest() { val vm = InAppReviewViewModel( manager = manager, storeReviewManager = storeReviewManager, + prefs = mock() ) vm.uiState.test { @@ -185,6 +189,7 @@ class InAppReviewViewModelTest : BaseViewModelTest() { val vm = InAppReviewViewModel( manager = manager, storeReviewManager = storeReviewManager, + prefs = mock() ) vm.uiState.test { diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index dcd853dd0c..d0982ac176 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -291,4 +291,12 @@ action-item-icon qa-blocked-contacts-settings-item + + cta-body + cta-feature- + cta-button-positive + cta-button-negative + + qa-collapsing-footer-action + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9fed3068fe..6f4947f369 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,11 +6,11 @@ accompanistPermissionsVersion = "0.37.3" activityKtxVersion = "1.10.1" androidImageCropperVersion = "4.6.0" androidVersion = "137.7151.04" -assertjCoreVersion = "3.27.4" +assertjCoreVersion = "3.27.6" biometricVersion = "1.1.0" -cameraCamera2Version = "1.5.0" +cameraCamera2Version = "1.5.1" cardviewVersion = "1.0.0" -composeBomVersion = "2025.09.01" +composeBomVersion = "2025.11.01" conscryptAndroidVersion = "2.5.3" conscryptJavaVersion = "2.5.2" constraintlayoutVersion = "2.2.1" @@ -21,22 +21,22 @@ exifinterfaceVersion = "1.4.1" firebaseMessagingVersion = "25.0.1" flexboxVersion = "3.0.0" fragmentKtxVersion = "1.8.9" -gradlePluginVersion = "8.13.0" -dependenciesAnalysisVersion = "2.17.0" -googleServicesVersion = "4.4.3" +gradlePluginVersion = "8.13.1" +dependenciesAnalysisVersion = "3.1.0" +googleServicesVersion = "4.4.4" junit = "1.3.0" -kotlinVersion = "2.2.20" +kotlinVersion = "2.2.21" kryoVersion = "5.6.2" -kspVersion = "2.2.10-2.0.2" +kspVersion = "2.3.3" legacySupportV13Version = "1.0.0" -libsessionUtilAndroidVersion = "1.0.9-2-g8c03d1e" +libsessionUtilAndroidVersion = "1.0.9-7-gf2e3e9b" media3ExoplayerVersion = "1.8.0" mockitoCoreVersion = "5.20.0" -navVersion = "2.9.4" +navVersion = "2.9.5" appcompatVersion = "1.7.1" coreVersion = "1.16.0" coroutinesVersion = "1.10.2" -daggerHiltVersion = "2.57.1" +daggerHiltVersion = "2.57.2" androidxHiltVersion = "1.3.0" glideVersion = "5.0.5" jacksonDatabindVersion = "2.9.8" @@ -44,15 +44,15 @@ junitVersion = "4.13.2" kotlinxJsonVersion = "1.9.0" kovenantVersion = "3.3.0" opencsvVersion = "5.12.0" -orchestratorVersion = "1.5.1" +orchestratorVersion = "1.6.1" photoviewVersion = "2.3.0" phraseVersion = "1.2.0" lifecycleVersion = "2.9.4" materialVersion = "1.13.0" -mockitoKotlinVersion = "6.0.0" -okhttpVersion = "5.1.0" +mockitoKotlinVersion = "6.1.0" +okhttpVersion = "5.3.0" preferenceVersion = "1.2.1" -protobufVersion = "4.32.1" +protobufVersion = "4.33.0" recyclerviewVersion = "1.4.0" robolectricVersion = "4.14.1" roundedimageviewVersion = "2.1.0" @@ -60,14 +60,14 @@ runnerVersion = "1.7.0" rxbindingVersion = "3.1.0" sqlcipherAndroidVersion = "4.9.0" streamVersion = "1.1.8" -sqliteKtxVersion = "2.5.2" +sqliteKtxVersion = "2.6.2" subsamplingScaleImageViewVersion = "3.10.0" testCoreVersion = "1.7.0" truthVersion = "1.4.5" turbineVersion = "1.2.1" -uiTestJunit4Version = "1.9.2" +uiTestJunit4Version = "1.9.4" workRuntimeKtxVersion = "2.10.5" -zxingVersion = "3.5.3" +zxingVersion = "3.5.4" huaweiPushVersion = "6.13.0.300" googlePlayReviewVersion = "2.0.2" coilVersion = "3.3.0" @@ -169,8 +169,8 @@ turbine = { module = "app.cash.turbine:turbine", version.ref = "turbineVersion" huawei-push = { module = 'com.huawei.hms:push', version.ref = 'huaweiPushVersion' } google-play-review = { module = "com.google.android.play:review", version.ref = "googlePlayReviewVersion" } google-play-review-ktx = { module = "com.google.android.play:review-ktx", version.ref = "googlePlayReviewVersion" } -sqlite-web-viewer = { module = "io.github.simophin:sqlite-web-viewer", version = "0.2.0" } -protoc = { module = "com.google.protobuf:protoc", version = "4.31.1" } +sqlite-web-viewer = { module = "io.github.simophin:sqlite-web-viewer", version = "0.0.3" } +protoc = { module = "com.google.protobuf:protoc", version = "4.32.1" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilVersion" } coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coilVersion" } android-billing = { module = "com.android.billingclient:billing", version.ref = "billingVersion" }