diff --git a/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json b/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json index 6bcf1c65..c2338b6c 100644 --- a/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json +++ b/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "f727ada9d0ee39552687eb25fef6f248", + "identityHash": "d5edc6419007841d2365ac43dd8af292", "entities": [ { "tableName": "account", @@ -38,7 +38,7 @@ }, { "tableName": "contacts", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ownerVeraId` TEXT NOT NULL, `contactVeraId` TEXT NOT NULL, `alias` TEXT, `contactEndpointId` TEXT, `status` TEXT NOT NULL, FOREIGN KEY(`ownerVeraId`) REFERENCES `account`(`veraId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ownerVeraId` TEXT NOT NULL, `contactVeraId` TEXT NOT NULL, `alias` TEXT, `contactEndpointId` TEXT, `status` INTEGER NOT NULL, FOREIGN KEY(`ownerVeraId`) REFERENCES `account`(`veraId`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", @@ -73,7 +73,7 @@ { "fieldPath": "status", "columnName": "status", - "affinity": "TEXT", + "affinity": "INTEGER", "notNull": true } ], @@ -112,7 +112,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f727ada9d0ee39552687eb25fef6f248')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd5edc6419007841d2365ac43dd8af292')" ] } } \ No newline at end of file diff --git a/app/src/main/java/tech/relaycorp/letro/awala/AwalaManager.kt b/app/src/main/java/tech/relaycorp/letro/awala/AwalaManager.kt index 67fdba30..08a18d0f 100644 --- a/app/src/main/java/tech/relaycorp/letro/awala/AwalaManager.kt +++ b/app/src/main/java/tech/relaycorp/letro/awala/AwalaManager.kt @@ -1,6 +1,7 @@ package tech.relaycorp.letro.awala import android.content.Context +import android.util.Base64 import android.util.Log import androidx.annotation.RawRes import dagger.hilt.android.qualifiers.ApplicationContext @@ -37,6 +38,11 @@ interface AwalaManager { recipient: MessageRecipient, ) suspend fun isAwalaInstalled(currentScreen: Route): Boolean + suspend fun authorizeUsers( + // TODO: after MVP handle several first party endpoints + thirdPartyPublicKey: ByteArray, + ) + suspend fun getFirstPartyPublicKey(): String } @OptIn(ExperimentalCoroutinesApi::class) @@ -63,7 +69,6 @@ class AwalaManagerImpl @Inject constructor( private var isReceivingMessages = false private var firstPartyEndpoint: FirstPartyEndpoint? = null - private var thirdPartyServerEndpoint: ThirdPartyEndpoint? = null init { @@ -116,6 +121,27 @@ class AwalaManagerImpl @Inject constructor( return isInstalled } + override suspend fun authorizeUsers(thirdPartyPublicKey: ByteArray) { + withContext(awalaThreadContext) { + val firstPartyEndpoint = loadFirstPartyEndpoint() + val auth = firstPartyEndpoint.authorizeIndefinitely(thirdPartyPublicKey) + sendMessage( + outgoingMessage = AwalaOutgoingMessage( + type = MessageType.ContactPairingAuthorization, + content = auth, + ), + recipient = MessageRecipient.Server(), + ) + } + } + + override suspend fun getFirstPartyPublicKey(): String { + return withContext(awalaThreadContext) { + val firstPartyEndpoint = loadFirstPartyEndpoint() + Base64.encodeToString(firstPartyEndpoint.publicKey.encoded, Base64.NO_WRAP) + } + } + private suspend fun loadFirstPartyEndpoint(): FirstPartyEndpoint { return withContext(awalaThreadContext) { val firstPartyEndpointNodeId = awalaRepository.getServerFirstPartyEndpointNodeId() @@ -159,7 +185,7 @@ class AwalaManagerImpl @Inject constructor( Log.i(TAG, "start receiving messages...") GatewayClient.receiveMessages().collect { message -> Log.i(TAG, "Receive message: ${message.type}: ($message)") - processor.process(message) + processor.process(message, this@AwalaManagerImpl) Log.i(TAG, "Message processed") message.ack() } diff --git a/app/src/main/java/tech/relaycorp/letro/awala/message/MessageType.kt b/app/src/main/java/tech/relaycorp/letro/awala/message/MessageType.kt index b0b88b30..7a912a08 100644 --- a/app/src/main/java/tech/relaycorp/letro/awala/message/MessageType.kt +++ b/app/src/main/java/tech/relaycorp/letro/awala/message/MessageType.kt @@ -1,5 +1,8 @@ package tech.relaycorp.letro.awala.message +import android.util.Log +import tech.relaycorp.letro.awala.AwalaManagerImpl + sealed class MessageType(val value: String) { object AccountCreationRequest : MessageType("application/vnd.relaycorp.letro.account-creation-request") object AccountCreationCompleted : MessageType("application/vnd.relaycorp.letro.account-creation-completed-tmp") @@ -18,7 +21,10 @@ sealed class MessageType(val value: String) { ContactPairingRequest.value -> ContactPairingRequest ContactPairingMatch.value -> ContactPairingMatch ContactPairingAuthorization.value -> ContactPairingAuthorization - else -> Unknown + else -> { + Log.e(AwalaManagerImpl.TAG, "Unknown message type $type") + Unknown + } } } } diff --git a/app/src/main/java/tech/relaycorp/letro/awala/parser/UnknownMessageParser.kt b/app/src/main/java/tech/relaycorp/letro/awala/parser/UnknownMessageParser.kt index c66313ef..e69de29b 100644 --- a/app/src/main/java/tech/relaycorp/letro/awala/parser/UnknownMessageParser.kt +++ b/app/src/main/java/tech/relaycorp/letro/awala/parser/UnknownMessageParser.kt @@ -1,22 +0,0 @@ -package tech.relaycorp.letro.awala.parser - -import tech.relaycorp.letro.awala.message.AwalaIncomingMessage -import tech.relaycorp.letro.awala.message.MessageType -import javax.inject.Inject - -interface UnknownMessageParser : AwalaMessageParser - -class UnknownMessageParserImpl @Inject constructor() : UnknownMessageParser { - - override fun parse(content: ByteArray): AwalaIncomingMessage<*> { - return UnknownIncomingMessage( - content = content.decodeToString(), - ) - } -} - -data class UnknownIncomingMessage( - override val content: String, -) : AwalaIncomingMessage { - override val type: MessageType = MessageType.Unknown -} diff --git a/app/src/main/java/tech/relaycorp/letro/awala/processor/AwalaMessageProcessor.kt b/app/src/main/java/tech/relaycorp/letro/awala/processor/AwalaMessageProcessor.kt index a935e9ff..3e08984f 100644 --- a/app/src/main/java/tech/relaycorp/letro/awala/processor/AwalaMessageProcessor.kt +++ b/app/src/main/java/tech/relaycorp/letro/awala/processor/AwalaMessageProcessor.kt @@ -1,18 +1,19 @@ package tech.relaycorp.letro.awala.processor import tech.relaycorp.awaladroid.messaging.IncomingMessage +import tech.relaycorp.letro.awala.AwalaManager import tech.relaycorp.letro.awala.message.MessageType interface AwalaMessageProcessor { - suspend fun process(message: IncomingMessage) + suspend fun process(message: IncomingMessage, awalaManager: AwalaManager) } class AwalaMessageProcessorImpl constructor( private val processors: Map, ) : AwalaMessageProcessor { - override suspend fun process(message: IncomingMessage) { + override suspend fun process(message: IncomingMessage, awalaManager: AwalaManager) { val type = MessageType.from(message.type) - processors[type]!!.process(message) + processors[type]!!.process(message, awalaManager) } } diff --git a/app/src/main/java/tech/relaycorp/letro/awala/processor/UnknownMessageProcessor.kt b/app/src/main/java/tech/relaycorp/letro/awala/processor/UnknownMessageProcessor.kt index 08478eb7..ad0bc940 100644 --- a/app/src/main/java/tech/relaycorp/letro/awala/processor/UnknownMessageProcessor.kt +++ b/app/src/main/java/tech/relaycorp/letro/awala/processor/UnknownMessageProcessor.kt @@ -2,12 +2,13 @@ package tech.relaycorp.letro.awala.processor import android.util.Log import tech.relaycorp.awaladroid.messaging.IncomingMessage +import tech.relaycorp.letro.awala.AwalaManager import tech.relaycorp.letro.awala.AwalaManagerImpl import javax.inject.Inject class UnknownMessageProcessor @Inject constructor() : AwalaMessageProcessor { - override suspend fun process(message: IncomingMessage) { + override suspend fun process(message: IncomingMessage, awalaManager: AwalaManager) { Log.w(AwalaManagerImpl.TAG, "Unknown message processor for type: ${message.type}") } } diff --git a/app/src/main/java/tech/relaycorp/letro/contacts/model/Contact.kt b/app/src/main/java/tech/relaycorp/letro/contacts/model/Contact.kt index e0d760af..d84f1532 100644 --- a/app/src/main/java/tech/relaycorp/letro/contacts/model/Contact.kt +++ b/app/src/main/java/tech/relaycorp/letro/contacts/model/Contact.kt @@ -1,10 +1,16 @@ package tech.relaycorp.letro.contacts.model +import androidx.annotation.IntDef import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey import tech.relaycorp.letro.account.model.Account +import tech.relaycorp.letro.contacts.model.ContactPairingStatus.Companion.AUTHORIZATION_SENT +import tech.relaycorp.letro.contacts.model.ContactPairingStatus.Companion.COMPLETED +import tech.relaycorp.letro.contacts.model.ContactPairingStatus.Companion.MATCH +import tech.relaycorp.letro.contacts.model.ContactPairingStatus.Companion.REQUEST_SENT +import tech.relaycorp.letro.contacts.model.ContactPairingStatus.Companion.UNPAIRED const val TABLE_NAME_CONTACTS = "contacts" @@ -22,18 +28,21 @@ const val TABLE_NAME_CONTACTS = "contacts" ) data class Contact( @PrimaryKey(autoGenerate = true) - val id: Long, + val id: Long = 0L, val ownerVeraId: String, val contactVeraId: String, val alias: String? = null, val contactEndpointId: String? = null, - val status: ContactPairingStatus = ContactPairingStatus.Unpaired, + @ContactPairingStatus val status: Int = UNPAIRED, ) -sealed interface ContactPairingStatus { - object Unpaired : ContactPairingStatus - object RequestSent : ContactPairingStatus - object Match : ContactPairingStatus - object AuthorizationSent : ContactPairingStatus - object Complete : ContactPairingStatus +@IntDef(UNPAIRED, REQUEST_SENT, MATCH, AUTHORIZATION_SENT, COMPLETED) +annotation class ContactPairingStatus { + companion object { + const val UNPAIRED = 0 + const val REQUEST_SENT = 1 + const val MATCH = 2 + const val AUTHORIZATION_SENT = 3 + const val COMPLETED = 4 + } } diff --git a/app/src/main/java/tech/relaycorp/letro/contacts/storage/ContactsDao.kt b/app/src/main/java/tech/relaycorp/letro/contacts/storage/ContactsDao.kt index 64f514e1..e82a591d 100644 --- a/app/src/main/java/tech/relaycorp/letro/contacts/storage/ContactsDao.kt +++ b/app/src/main/java/tech/relaycorp/letro/contacts/storage/ContactsDao.kt @@ -14,6 +14,12 @@ interface ContactsDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(contact: Contact): Long + @Query("SELECT * FROM $TABLE_NAME_CONTACTS WHERE ownerVeraId = :ownerVeraId AND contactVeraId = :contactVeraId") + suspend fun getContact( + ownerVeraId: String, + contactVeraId: String, + ): Contact? + @Update suspend fun update(contact: Contact) diff --git a/app/src/main/java/tech/relaycorp/letro/contacts/storage/ContactsRepository.kt b/app/src/main/java/tech/relaycorp/letro/contacts/storage/ContactsRepository.kt index 8e0b431d..de7a4761 100644 --- a/app/src/main/java/tech/relaycorp/letro/contacts/storage/ContactsRepository.kt +++ b/app/src/main/java/tech/relaycorp/letro/contacts/storage/ContactsRepository.kt @@ -9,6 +9,10 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import tech.relaycorp.letro.account.model.Account import tech.relaycorp.letro.account.storage.AccountRepository +import tech.relaycorp.letro.awala.AwalaManager +import tech.relaycorp.letro.awala.message.AwalaOutgoingMessage +import tech.relaycorp.letro.awala.message.MessageRecipient +import tech.relaycorp.letro.awala.message.MessageType import tech.relaycorp.letro.contacts.model.Contact import tech.relaycorp.letro.contacts.model.ContactPairingStatus import javax.inject.Inject @@ -16,16 +20,20 @@ import javax.inject.Inject interface ContactsRepository { val isPairedContactsExist: Flow fun getContacts(ownerVeraId: String): Flow> + + fun addNewContact(contact: Contact) } class ContactsRepositoryImpl @Inject constructor( private val contactsDao: ContactsDao, private val accountRepository: AccountRepository, + private val awalaManager: AwalaManager, ) : ContactsRepository { private val scope = CoroutineScope(Dispatchers.IO) private val contacts = MutableStateFlow>(emptyList()) + private var currentAccount: Account? = null private val _isPairedContactsExist: MutableStateFlow = MutableStateFlow(false) override val isPairedContactsExist: StateFlow get() = _isPairedContactsExist @@ -35,6 +43,7 @@ class ContactsRepositoryImpl @Inject constructor( contactsDao.getAll().collect { contacts.emit(it) startCollectAccountFlow() + updatePairedContactExist(currentAccount) } } } @@ -44,9 +53,42 @@ class ContactsRepositoryImpl @Inject constructor( .map { it.filter { it.ownerVeraId == ownerVeraId } } } + override fun addNewContact(contact: Contact) { + scope.launch { + val existingContact = contactsDao.getContact( + ownerVeraId = contact.ownerVeraId, + contactVeraId = contact.contactVeraId, + ) + + if (existingContact == null || existingContact.status <= ContactPairingStatus.REQUEST_SENT) { + if (existingContact == null) { + contactsDao.insert( + contact.copy( + status = ContactPairingStatus.REQUEST_SENT, + ), + ) + } else { + contactsDao.update( + contact.copy( + status = ContactPairingStatus.REQUEST_SENT, + ), + ) + } + awalaManager.sendMessage( + outgoingMessage = AwalaOutgoingMessage( + type = MessageType.ContactPairingRequest, + content = "${contact.ownerVeraId},${contact.contactVeraId},${awalaManager.getFirstPartyPublicKey()}".toByteArray(), + ), + recipient = MessageRecipient.Server(), + ) + } + } + } + private fun startCollectAccountFlow() { scope.launch { accountRepository.currentAccount.collect { + currentAccount = it updatePairedContactExist(it) } } @@ -61,8 +103,7 @@ class ContactsRepositoryImpl @Inject constructor( contacts .value .any { - it.ownerVeraId == account.veraId && - it.status == ContactPairingStatus.Complete + it.ownerVeraId == account.veraId && it.status == ContactPairingStatus.COMPLETED }, ) } diff --git a/app/src/main/java/tech/relaycorp/letro/contacts/storage/converter/ContactPairingStatusConverter.kt b/app/src/main/java/tech/relaycorp/letro/contacts/storage/converter/ContactPairingStatusConverter.kt deleted file mode 100644 index d8b876b9..00000000 --- a/app/src/main/java/tech/relaycorp/letro/contacts/storage/converter/ContactPairingStatusConverter.kt +++ /dev/null @@ -1,29 +0,0 @@ -package tech.relaycorp.letro.contacts.storage.converter - -import androidx.room.TypeConverter -import tech.relaycorp.letro.contacts.model.ContactPairingStatus - -class ContactPairingStatusConverter { - @TypeConverter - fun toPairingStatus(value: String): ContactPairingStatus { - return when (value) { - "Unpaired" -> ContactPairingStatus.Unpaired - "RequestSent" -> ContactPairingStatus.RequestSent - "Match" -> ContactPairingStatus.Match - "AuthorizationSent" -> ContactPairingStatus.AuthorizationSent - "Complete" -> ContactPairingStatus.Complete - else -> throw IllegalArgumentException("Unknown pairing status") - } - } - - @TypeConverter - fun fromPairingStatus(status: ContactPairingStatus): String { - return when (status) { - is ContactPairingStatus.Unpaired -> "Unpaired" - is ContactPairingStatus.RequestSent -> "RequestSent" - is ContactPairingStatus.Match -> "Match" - is ContactPairingStatus.AuthorizationSent -> "AuthorizationSent" - is ContactPairingStatus.Complete -> "Complete" - } - } -} diff --git a/app/src/main/java/tech/relaycorp/letro/di/AwalaModule.kt b/app/src/main/java/tech/relaycorp/letro/di/AwalaModule.kt index eba08369..e844c3ec 100644 --- a/app/src/main/java/tech/relaycorp/letro/di/AwalaModule.kt +++ b/app/src/main/java/tech/relaycorp/letro/di/AwalaModule.kt @@ -10,12 +10,11 @@ import tech.relaycorp.letro.awala.AwalaManagerImpl import tech.relaycorp.letro.awala.AwalaRepository import tech.relaycorp.letro.awala.AwalaRepositoryImpl import tech.relaycorp.letro.awala.message.MessageType -import tech.relaycorp.letro.awala.parser.UnknownMessageParser -import tech.relaycorp.letro.awala.parser.UnknownMessageParserImpl import tech.relaycorp.letro.awala.processor.AwalaMessageProcessor import tech.relaycorp.letro.awala.processor.AwalaMessageProcessorImpl import tech.relaycorp.letro.awala.processor.UnknownMessageProcessor import tech.relaycorp.letro.onboarding.registration.processor.RegistrationMessageProcessor +import tech.relaycorp.letro.pairing.processor.ContactPairingMatchProcessor import javax.inject.Singleton @Module @@ -25,10 +24,12 @@ object AwalaModule { @Provides fun provideMessageProcessor( registrationMessageProcessor: RegistrationMessageProcessor, + contactPairingMatchProcessor: ContactPairingMatchProcessor, unknownMessageProcessor: UnknownMessageProcessor, ): AwalaMessageProcessor { val processors = mapOf( MessageType.AccountCreationCompleted to registrationMessageProcessor, + MessageType.ContactPairingMatch to contactPairingMatchProcessor, MessageType.Unknown to unknownMessageProcessor, ) return AwalaMessageProcessorImpl(processors) @@ -48,10 +49,5 @@ object AwalaModule { fun bindAwalaRepository( impl: AwalaRepositoryImpl, ): AwalaRepository - - @Binds - fun bindUnknownMessageParser( - impl: UnknownMessageParserImpl, - ): UnknownMessageParser } } diff --git a/app/src/main/java/tech/relaycorp/letro/di/ContactsModule.kt b/app/src/main/java/tech/relaycorp/letro/di/ContactsModule.kt index 9a2f56ef..79ad02c8 100644 --- a/app/src/main/java/tech/relaycorp/letro/di/ContactsModule.kt +++ b/app/src/main/java/tech/relaycorp/letro/di/ContactsModule.kt @@ -8,6 +8,10 @@ import dagger.hilt.components.SingletonComponent import tech.relaycorp.letro.contacts.storage.ContactsDao import tech.relaycorp.letro.contacts.storage.ContactsRepository import tech.relaycorp.letro.contacts.storage.ContactsRepositoryImpl +import tech.relaycorp.letro.pairing.parser.ContactPairingMatchParser +import tech.relaycorp.letro.pairing.parser.ContactPairingMatchParserImpl +import tech.relaycorp.letro.pairing.processor.ContactPairingMatchProcessor +import tech.relaycorp.letro.pairing.processor.ContactPairingMatchProcessorImpl import tech.relaycorp.letro.storage.LetroDatabase import javax.inject.Singleton @@ -28,5 +32,16 @@ object ContactsModule { fun bindContactsRepository( impl: ContactsRepositoryImpl, ): ContactsRepository + + @Binds + fun bindContactPairingMatchParser( + impl: ContactPairingMatchParserImpl, + ): ContactPairingMatchParser + + @Binds + @Singleton + fun bindContactPairingMatchProcessor( + impl: ContactPairingMatchProcessorImpl, + ): ContactPairingMatchProcessor } } diff --git a/app/src/main/java/tech/relaycorp/letro/main/MainViewModel.kt b/app/src/main/java/tech/relaycorp/letro/main/MainViewModel.kt index 3241b31c..54239e32 100644 --- a/app/src/main/java/tech/relaycorp/letro/main/MainViewModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/main/MainViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import tech.relaycorp.letro.account.model.Account @@ -73,27 +74,33 @@ class MainViewModel @Inject constructor( accountRepository.currentAccount, contactsRepository.isPairedContactsExist, ) { currentAccount, isPairedContactExist -> - Log.d(TAG, "$currentAccount; $isPairedContactExist") - if (currentAccount != null) { - when { - !currentAccount.isCreated -> { - isRegistration = true - _rootNavigationScreen.emit(RootNavigationScreen.RegistrationWaiting) - } - !isPairedContactExist -> { - if (isRegistration) { - _rootNavigationScreen.emit(RootNavigationScreen.WelcomeToLetro) - } else { - _rootNavigationScreen.emit(RootNavigationScreen.NoContactsScreen) + Pair(currentAccount, isPairedContactExist) + } + .distinctUntilChanged() + .collect { + val currentAccount = it.first + val isPairedContactExist = it.second + Log.d(TAG, "$currentAccount; $isPairedContactExist") + if (currentAccount != null) { + when { + !currentAccount.isCreated -> { + isRegistration = true + _rootNavigationScreen.emit(RootNavigationScreen.RegistrationWaiting) } - isRegistration = false + !isPairedContactExist -> { + if (isRegistration) { + _rootNavigationScreen.emit(RootNavigationScreen.WelcomeToLetro) + } else { + _rootNavigationScreen.emit(RootNavigationScreen.NoContactsScreen) + } + isRegistration = false + } + isPairedContactExist -> _rootNavigationScreen.emit(RootNavigationScreen.Conversations) } - isPairedContactExist -> _rootNavigationScreen.emit(RootNavigationScreen.Conversations) + } else { + _rootNavigationScreen.emit(RootNavigationScreen.Registration) } - } else { - _rootNavigationScreen.emit(RootNavigationScreen.Registration) } - }.collect() } } diff --git a/app/src/main/java/tech/relaycorp/letro/onboarding/actionTaking/ActionTakingScreenUIStateModel.kt b/app/src/main/java/tech/relaycorp/letro/onboarding/actionTaking/ActionTakingScreenUIStateModel.kt index baf11068..639ee76a 100644 --- a/app/src/main/java/tech/relaycorp/letro/onboarding/actionTaking/ActionTakingScreenUIStateModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/onboarding/actionTaking/ActionTakingScreenUIStateModel.kt @@ -16,7 +16,7 @@ sealed class ActionTakingScreenUIStateModel( class NoContacts( @DrawableRes image: Int, - onPairWithPeople: () -> Unit, + onPairWithOthersClick: () -> Unit, onShareId: () -> Unit, @StringRes title: Int? = null, @StringRes message: Int? = null, @@ -26,7 +26,7 @@ sealed class ActionTakingScreenUIStateModel( messageStringRes = message, buttonFilledStringRes = R.string.general_pair_with_others, buttonOutlinedStringRes = R.string.onboarding_account_confirmation_share_your_id, - onButtonFilledClicked = onPairWithPeople, + onButtonFilledClicked = onPairWithOthersClick, onButtonOutlinedClicked = onShareId, ) diff --git a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/processor/RegistrationMessageProcessor.kt b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/processor/RegistrationMessageProcessor.kt index 9fa4b700..265afdca 100644 --- a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/processor/RegistrationMessageProcessor.kt +++ b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/processor/RegistrationMessageProcessor.kt @@ -2,6 +2,7 @@ package tech.relaycorp.letro.onboarding.registration.processor import tech.relaycorp.awaladroid.messaging.IncomingMessage import tech.relaycorp.letro.account.storage.AccountRepository +import tech.relaycorp.letro.awala.AwalaManager import tech.relaycorp.letro.awala.processor.AwalaMessageProcessor import tech.relaycorp.letro.onboarding.registration.dto.RegistrationResponseIncomingMessage import tech.relaycorp.letro.onboarding.registration.parser.RegistrationMessageParser @@ -14,7 +15,7 @@ class RegistrationMessageProcessorImpl @Inject constructor( private val accountRepository: AccountRepository, ) : RegistrationMessageProcessor { - override suspend fun process(message: IncomingMessage) { + override suspend fun process(message: IncomingMessage, awalaManager: AwalaManager) { val response = parser.parse(message.content) as RegistrationResponseIncomingMessage accountRepository.updateAccountId(response.content.requestedVeraId, response.content.assignedVeraId) } diff --git a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/ui/RegistrationScreen.kt b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/ui/RegistrationScreen.kt index 324e86f0..08dc45da 100644 --- a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/ui/RegistrationScreen.kt +++ b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/ui/RegistrationScreen.kt @@ -4,16 +4,12 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.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.shape.RoundedCornerShape import androidx.compose.material3.Divider -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -31,6 +27,7 @@ import tech.relaycorp.letro.onboarding.registration.RegistrationViewModel import tech.relaycorp.letro.ui.common.ButtonType import tech.relaycorp.letro.ui.common.HyperlinkText import tech.relaycorp.letro.ui.common.LetroButtonMaxWidthFilled +import tech.relaycorp.letro.ui.common.LetroInfoView import tech.relaycorp.letro.ui.common.LetroOutlinedTextField import tech.relaycorp.letro.ui.theme.HorizontalScreenPadding import tech.relaycorp.letro.ui.theme.LetroTheme @@ -73,7 +70,7 @@ fun RegistrationScreen( LetroOutlinedTextField( value = uiState.username, onValueChange = { viewModel.onUsernameInput(it) }, - placeHolderText = stringResource(id = R.string.onboarding_create_account_id_placeholder), + hintText = stringResource(id = R.string.onboarding_create_account_id_placeholder), suffixText = uiState.domain, isError = uiState.isError, ) @@ -88,24 +85,7 @@ fun RegistrationScreen( Spacer( modifier = Modifier.height(16.dp), ) - Row( - modifier = Modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(8.dp), - ) - .padding(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - painter = painterResource(id = R.drawable.info), - tint = MaterialTheme.colorScheme.onPrimaryContainer, - contentDescription = null, - ) - Spacer( - modifier = Modifier.width(8.dp), - ) + LetroInfoView { HyperlinkText( fullText = stringResource(id = R.string.onboarding_create_account_terms_and_services), hyperLinks = mapOf( diff --git a/app/src/main/java/tech/relaycorp/letro/pairing/PairWithOthersViewModel.kt b/app/src/main/java/tech/relaycorp/letro/pairing/PairWithOthersViewModel.kt new file mode 100644 index 00000000..c0ca8cb2 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/pairing/PairWithOthersViewModel.kt @@ -0,0 +1,113 @@ +package tech.relaycorp.letro.pairing + +import androidx.annotation.StringRes +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import tech.relaycorp.letro.R +import tech.relaycorp.letro.contacts.model.Contact +import tech.relaycorp.letro.contacts.model.ContactPairingStatus +import tech.relaycorp.letro.contacts.storage.ContactsRepository +import javax.inject.Inject + +@HiltViewModel +class PairWithOthersViewModel @Inject constructor( + private val contactsRepository: ContactsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val currentAccountId: String? = savedStateHandle[KEY_CURRENT_ACCOUNT_ID] + + private val _uiState = MutableStateFlow(PairWithOthersUiState()) + val uiState: StateFlow + get() = _uiState + + private val _backSignal = MutableSharedFlow() + val backSignal: SharedFlow + get() = _backSignal + + private val contacts: HashSet = hashSetOf() + + init { + viewModelScope.launch { + currentAccountId?.let { currentAccountId -> + contactsRepository.getContacts(currentAccountId).collect { + contacts.clear() + contacts.addAll(it) + } + } + } + } + + fun onIdChanged(id: String) { + viewModelScope.launch { + val trimmedId = id.trim() + _uiState.update { + it.copy( + id = trimmedId, + isSentRequestAgainHintVisible = contacts.any { it.contactVeraId == trimmedId && it.status == ContactPairingStatus.REQUEST_SENT }, + pairingErrorCaption = getPairingErrorMessage(trimmedId), + ) + } + } + } + + fun onAliasChanged(alias: String) { + viewModelScope.launch { + _uiState.update { + it.copy( + alias = alias, + ) + } + } + } + + fun onPairRequestClick() { + currentAccountId?.let { currentAccountId -> + contactsRepository.addNewContact( + contact = Contact( + ownerVeraId = currentAccountId, + contactVeraId = uiState.value.id, + alias = uiState.value.alias, + status = ContactPairingStatus.REQUEST_SENT, + ), + ) + } + viewModelScope.launch(Dispatchers.IO) { + _backSignal.emit(Unit) + } + } + + private fun getPairingErrorMessage(contactId: String): PairingErrorCaption? { + val contact = contacts.find { it.contactVeraId == contactId } + return when { + contact == null -> null + contact.status == ContactPairingStatus.COMPLETED -> PairingErrorCaption(R.string.pair_request_already_paired) + contact.status >= ContactPairingStatus.MATCH -> PairingErrorCaption(R.string.pair_request_already_in_progress) + else -> null + } + } + + companion object { + const val KEY_CURRENT_ACCOUNT_ID = "current_account_id" + } +} + +data class PairWithOthersUiState( + val id: String = "", + val alias: String = "", + val isSentRequestAgainHintVisible: Boolean = false, + val pairingErrorCaption: PairingErrorCaption? = null, +) + +data class PairingErrorCaption( + @StringRes val message: Int, +) diff --git a/app/src/main/java/tech/relaycorp/letro/pairing/dto/ContactPairingMatchResponse.kt b/app/src/main/java/tech/relaycorp/letro/pairing/dto/ContactPairingMatchResponse.kt new file mode 100644 index 00000000..4720f8db --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/pairing/dto/ContactPairingMatchResponse.kt @@ -0,0 +1,18 @@ +package tech.relaycorp.letro.pairing.dto + +import tech.relaycorp.letro.awala.message.AwalaIncomingMessage +import tech.relaycorp.letro.awala.message.MessageType + +data class ContactPairingMatchResponse( + val ownerVeraId: String, + val contactVeraId: String, + val contactEndpointId: String, + val contactEndpointPublicKey: ByteArray, +) + +data class ContactPairingMatchIncomingMessage( + override val content: ContactPairingMatchResponse, +) : AwalaIncomingMessage { + override val type: MessageType + get() = MessageType.ContactPairingMatch +} diff --git a/app/src/main/java/tech/relaycorp/letro/pairing/parser/ContactPairingMatchParser.kt b/app/src/main/java/tech/relaycorp/letro/pairing/parser/ContactPairingMatchParser.kt new file mode 100644 index 00000000..f4108e1b --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/pairing/parser/ContactPairingMatchParser.kt @@ -0,0 +1,26 @@ +package tech.relaycorp.letro.pairing.parser + +import android.util.Base64 +import tech.relaycorp.letro.awala.parser.AwalaMessageParser +import tech.relaycorp.letro.pairing.dto.ContactPairingMatchIncomingMessage +import tech.relaycorp.letro.pairing.dto.ContactPairingMatchResponse +import java.nio.charset.Charset +import javax.inject.Inject + +interface ContactPairingMatchParser : AwalaMessageParser + +class ContactPairingMatchParserImpl @Inject constructor() : ContactPairingMatchParser { + + override fun parse(content: ByteArray): ContactPairingMatchIncomingMessage { + val contentString = content.toString(Charset.defaultCharset()) + val parts = contentString.split(",") + return ContactPairingMatchIncomingMessage( + content = ContactPairingMatchResponse( + ownerVeraId = parts[0], + contactVeraId = parts[1], + contactEndpointId = parts[2], + contactEndpointPublicKey = Base64.decode(parts[3], Base64.NO_WRAP), + ), + ) + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/pairing/processor/ContactPairingMatchProcessor.kt b/app/src/main/java/tech/relaycorp/letro/pairing/processor/ContactPairingMatchProcessor.kt new file mode 100644 index 00000000..7426d994 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/pairing/processor/ContactPairingMatchProcessor.kt @@ -0,0 +1,34 @@ +package tech.relaycorp.letro.pairing.processor + +import tech.relaycorp.awaladroid.messaging.IncomingMessage +import tech.relaycorp.letro.awala.AwalaManager +import tech.relaycorp.letro.awala.processor.AwalaMessageProcessor +import tech.relaycorp.letro.contacts.model.ContactPairingStatus +import tech.relaycorp.letro.contacts.storage.ContactsDao +import tech.relaycorp.letro.pairing.dto.ContactPairingMatchIncomingMessage +import tech.relaycorp.letro.pairing.parser.ContactPairingMatchParser +import javax.inject.Inject + +interface ContactPairingMatchProcessor : AwalaMessageProcessor + +class ContactPairingMatchProcessorImpl @Inject constructor( + private val parser: ContactPairingMatchParser, + private val contactsDao: ContactsDao, +) : ContactPairingMatchProcessor { + + override suspend fun process(message: IncomingMessage, awalaManager: AwalaManager) { + val response = (parser.parse(message.content) as ContactPairingMatchIncomingMessage).content + contactsDao.getContact( + ownerVeraId = response.ownerVeraId, + contactVeraId = response.contactVeraId, + )?.let { contactToUpdate -> + contactsDao.update( + contactToUpdate.copy( + contactEndpointId = response.contactEndpointId, + status = ContactPairingStatus.MATCH, + ), + ) + } + awalaManager.authorizeUsers(response.contactEndpointPublicKey) + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/pairing/ui/PairWithOthersScreen.kt b/app/src/main/java/tech/relaycorp/letro/pairing/ui/PairWithOthersScreen.kt new file mode 100644 index 00000000..37dff129 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/pairing/ui/PairWithOthersScreen.kt @@ -0,0 +1,134 @@ +package tech.relaycorp.letro.pairing.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import tech.relaycorp.letro.R +import tech.relaycorp.letro.pairing.PairWithOthersViewModel +import tech.relaycorp.letro.ui.common.LetroButtonMaxWidthFilled +import tech.relaycorp.letro.ui.common.LetroInfoView +import tech.relaycorp.letro.ui.common.LetroOutlinedTextField +import tech.relaycorp.letro.ui.theme.HorizontalScreenPadding +import tech.relaycorp.letro.ui.theme.LetroTheme + +@Composable +fun PairWithOthersScreen( + onBackClick: () -> Unit, + viewModel: PairWithOthersViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsState() + LaunchedEffect(Unit) { + viewModel.backSignal.collect { + onBackClick() + } + } + + val errorCaption = uiState.pairingErrorCaption + + Column( + modifier = Modifier.padding( + horizontal = HorizontalScreenPadding, + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding( + vertical = 18.dp, + ), + ) { + Icon( + painter = painterResource(id = R.drawable.arrow_back), + contentDescription = stringResource(id = R.string.general_navigate_back), + Modifier.clickable { + onBackClick() + }, + ) + Spacer( + modifier = Modifier.width(16.dp), + ) + Text( + text = stringResource(id = R.string.general_pair_with_others), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + Spacer( + modifier = Modifier.height(8.dp), + ) + LetroOutlinedTextField( + value = uiState.id, + onValueChange = viewModel::onIdChanged, + label = R.string.general_id, + hintText = stringResource(id = R.string.new_contact_id_hint), + isError = errorCaption != null, + ) { + if (errorCaption != null) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = stringResource(id = errorCaption.message), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + if (uiState.isSentRequestAgainHintVisible) { + Spacer( + modifier = Modifier.height(8.dp), + ) + LetroInfoView { + Text( + text = stringResource(id = R.string.pair_request_was_already_sent_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + Spacer( + modifier = Modifier.height(24.dp), + ) + LetroOutlinedTextField( + value = uiState.alias, + onValueChange = viewModel::onAliasChanged, + label = R.string.onboarding_pair_with_people_alias, + hintText = stringResource(id = R.string.new_contact_alias_hint), + ) + Spacer( + modifier = Modifier.height(32.dp), + ) + LetroButtonMaxWidthFilled( + text = stringResource(id = R.string.onboarding_pair_with_people_button), + onClick = { viewModel.onPairRequestClick() }, + isEnabled = errorCaption == null, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun UseExistingAccountPreview() { + LetroTheme { + PairWithOthersScreen( + onBackClick = {}, + viewModel = hiltViewModel(), + ) + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/storage/LetroDatabase.kt b/app/src/main/java/tech/relaycorp/letro/storage/LetroDatabase.kt index bdc0f2e8..7e8639b8 100644 --- a/app/src/main/java/tech/relaycorp/letro/storage/LetroDatabase.kt +++ b/app/src/main/java/tech/relaycorp/letro/storage/LetroDatabase.kt @@ -2,12 +2,10 @@ package tech.relaycorp.letro.storage import androidx.room.Database import androidx.room.RoomDatabase -import androidx.room.TypeConverters import tech.relaycorp.letro.account.model.Account import tech.relaycorp.letro.account.storage.AccountDao import tech.relaycorp.letro.contacts.model.Contact import tech.relaycorp.letro.contacts.storage.ContactsDao -import tech.relaycorp.letro.contacts.storage.converter.ContactPairingStatusConverter @Database( entities = [ @@ -16,7 +14,6 @@ import tech.relaycorp.letro.contacts.storage.converter.ContactPairingStatusConve ], version = 1, ) -@TypeConverters(ContactPairingStatusConverter::class) abstract class LetroDatabase : RoomDatabase() { abstract fun accountDao(): AccountDao abstract fun contactsDao(): ContactsDao diff --git a/app/src/main/java/tech/relaycorp/letro/ui/common/HyperlinkText.kt b/app/src/main/java/tech/relaycorp/letro/ui/common/HyperlinkText.kt new file mode 100644 index 00000000..4b26c05c --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/ui/common/HyperlinkText.kt @@ -0,0 +1,71 @@ +package tech.relaycorp.letro.ui.common + +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.TextUnit + +@Composable +fun HyperlinkText( + fullText: String, + hyperLinks: Map, + modifier: Modifier = Modifier, + textColor: Color = MaterialTheme.colorScheme.onSurface, + linkTextColor: Color = MaterialTheme.colorScheme.primary, + linkTextFontWeight: FontWeight = FontWeight.Normal, + linkTextDecoration: TextDecoration = TextDecoration.None, + fontSize: TextUnit = TextUnit.Unspecified, +) { + val annotatedString = buildAnnotatedString { + append(fullText) + addStyle( + style = SpanStyle( + fontSize = fontSize, + color = textColor, + ), + start = 0, + end = fullText.length, + ) + for ((key, value) in hyperLinks) { + val startIndex = fullText.indexOf(key) + val endIndex = startIndex + key.length + addStyle( + style = SpanStyle( + color = linkTextColor, + fontSize = fontSize, + fontWeight = linkTextFontWeight, + textDecoration = linkTextDecoration, + ), + start = startIndex, + end = endIndex, + ) + addStringAnnotation( + tag = "URL", + annotation = value, + start = startIndex, + end = endIndex, + ) + } + } + + val uriHandler = LocalUriHandler.current + + ClickableText( + modifier = modifier, + text = annotatedString, + onClick = { + annotatedString + .getStringAnnotations("URL", it, it) + .firstOrNull()?.let { stringAnnotation -> + uriHandler.openUri(stringAnnotation.item) + } + }, + ) +} diff --git a/app/src/main/java/tech/relaycorp/letro/ui/common/LetroButton.kt b/app/src/main/java/tech/relaycorp/letro/ui/common/LetroButton.kt new file mode 100644 index 00000000..270bbc5b --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/ui/common/LetroButton.kt @@ -0,0 +1,92 @@ +package tech.relaycorp.letro.ui.common + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import tech.relaycorp.letro.ui.theme.LetroColor + +@Composable +fun LetroButton( + text: String, + modifier: Modifier = Modifier, + buttonType: ButtonType = ButtonType.Filled, + enabled: Boolean = true, + leadingIconResId: Int? = null, + onClick: () -> Unit, +) { + Button( + modifier = modifier, + shape = CircleShape, + enabled = enabled, + colors = ButtonDefaults.buttonColors( + containerColor = when (buttonType) { + ButtonType.Filled -> MaterialTheme.colorScheme.primary + ButtonType.Outlined -> MaterialTheme.colorScheme.surface + }, + contentColor = when (buttonType) { + ButtonType.Filled -> MaterialTheme.colorScheme.onPrimary + ButtonType.Outlined -> MaterialTheme.colorScheme.primary + }, + disabledContainerColor = LetroColor.disabledButtonBackgroundColor(), + disabledContentColor = LetroColor.disabledButtonTextColor(), + ), + border = if (buttonType == ButtonType.Outlined) { + BorderStroke( + color = MaterialTheme.colorScheme.primary, + width = 1.dp, + ) + } else { + null + }, + contentPadding = PaddingValues( + vertical = 14.dp, + ), + onClick = onClick, + ) { + if (leadingIconResId != null) { + Icon( + painter = painterResource(id = leadingIconResId), + contentDescription = null, + ) + } + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@Composable +fun LetroButtonMaxWidthFilled( + text: String, + modifier: Modifier = Modifier, + buttonType: ButtonType = ButtonType.Filled, + isEnabled: Boolean = true, + leadingIconResId: Int? = null, + onClick: () -> Unit, +) { + LetroButton( + text = text, + modifier = modifier + .fillMaxWidth(), + buttonType = buttonType, + enabled = isEnabled, + leadingIconResId = leadingIconResId, + onClick = onClick, + ) +} + +sealed interface ButtonType { + object Filled : ButtonType + object Outlined : ButtonType +} diff --git a/app/src/main/java/tech/relaycorp/letro/ui/common/LetroInfoView.kt b/app/src/main/java/tech/relaycorp/letro/ui/common/LetroInfoView.kt new file mode 100644 index 00000000..19564e4d --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/ui/common/LetroInfoView.kt @@ -0,0 +1,43 @@ +package tech.relaycorp.letro.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import tech.relaycorp.letro.R + +@Composable +fun LetroInfoView( + content: @Composable () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp), + ) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(id = R.drawable.info), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + contentDescription = null, + ) + Spacer( + modifier = Modifier.width(8.dp), + ) + content() + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/ui/common/LetroTextField.kt b/app/src/main/java/tech/relaycorp/letro/ui/common/LetroTextField.kt new file mode 100644 index 00000000..e4543c72 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/ui/common/LetroTextField.kt @@ -0,0 +1,114 @@ +package tech.relaycorp.letro.ui.common + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +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 + +@Composable +fun LetroTextField( + modifier: Modifier = Modifier, + value: String, + onValueChange: (String) -> Unit, + placeHolderText: String = "", + enabled: Boolean = true, + singleLine: Boolean = true, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, +) { + TextField( + modifier = modifier.fillMaxWidth(), + value = value, + onValueChange = onValueChange, + textStyle = MaterialTheme.typography.bodyLarge, + enabled = enabled, + singleLine = singleLine, + placeholder = { + Text( + text = placeHolderText, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + }, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + keyboardOptions = keyboardOptions, + ) +} + +@Composable +fun LetroOutlinedTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + @StringRes label: Int? = null, + hintText: String = "", + suffixText: String? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + isError: Boolean = false, + maxLines: Int = 1, + singleLine: Boolean = true, + content: (@Composable () -> Unit)? = null, +) { + Column { + if (label != null) { + Column { + Text( + text = stringResource(id = label), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer( + modifier = Modifier.height(8.dp), + ) + } + } + OutlinedTextField( + modifier = modifier.fillMaxWidth(), + value = value, + onValueChange = onValueChange, + shape = RoundedCornerShape(4.dp), + textStyle = MaterialTheme.typography.bodyLarge, + placeholder = { + Text( + text = hintText, + style = MaterialTheme.typography.bodyLarge, + ) + }, + suffix = { + if (suffixText != null) { + Text( + suffixText, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + }, + keyboardOptions = keyboardOptions, + maxLines = maxLines, + singleLine = singleLine, + isError = isError, + ) + if (content != null) { + content() + } + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/ui/common/Views.kt b/app/src/main/java/tech/relaycorp/letro/ui/common/Views.kt index fb806568..ac68c6d7 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/common/Views.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/common/Views.kt @@ -1,250 +1,16 @@ package tech.relaycorp.letro.ui.common -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.ClickableText -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import tech.relaycorp.letro.R -import tech.relaycorp.letro.ui.theme.LetroColor import tech.relaycorp.letro.ui.theme.LetroTheme -@Composable -fun LetroButton( - text: String, - modifier: Modifier = Modifier, - buttonType: ButtonType = ButtonType.Filled, - enabled: Boolean = true, - leadingIconResId: Int? = null, - onClick: () -> Unit, -) { - Button( - modifier = modifier, - shape = CircleShape, - enabled = enabled, - colors = ButtonDefaults.buttonColors( - containerColor = when (buttonType) { - ButtonType.Filled -> MaterialTheme.colorScheme.primary - ButtonType.Outlined -> MaterialTheme.colorScheme.surface - }, - contentColor = when (buttonType) { - ButtonType.Filled -> MaterialTheme.colorScheme.onPrimary - ButtonType.Outlined -> MaterialTheme.colorScheme.primary - }, - disabledContainerColor = LetroColor.disabledButtonBackgroundColor(), - disabledContentColor = LetroColor.disabledButtonTextColor(), - ), - border = if (buttonType == ButtonType.Outlined) { - BorderStroke( - color = MaterialTheme.colorScheme.primary, - width = 1.dp, - ) - } else { - null - }, - contentPadding = PaddingValues( - vertical = 14.dp, - ), - onClick = onClick, - ) { - if (leadingIconResId != null) { - Icon( - painter = painterResource(id = leadingIconResId), - contentDescription = null, - ) - } - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - ) - } -} - -@Composable -fun LetroButtonMaxWidthFilled( - text: String, - modifier: Modifier = Modifier, - buttonType: ButtonType = ButtonType.Filled, - isEnabled: Boolean = true, - leadingIconResId: Int? = null, - onClick: () -> Unit, -) { - LetroButton( - text = text, - modifier = modifier - .fillMaxWidth(), - buttonType = buttonType, - enabled = isEnabled, - leadingIconResId = leadingIconResId, - onClick = onClick, - ) -} - -sealed interface ButtonType { - object Filled : ButtonType - object Outlined : ButtonType -} - -@Composable -fun LetroTextField( - modifier: Modifier = Modifier, - value: String, - onValueChange: (String) -> Unit, - placeHolderText: String = "", - enabled: Boolean = true, - singleLine: Boolean = true, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, -) { - TextField( - modifier = modifier.fillMaxWidth(), - value = value, - onValueChange = onValueChange, - textStyle = MaterialTheme.typography.bodyLarge, - enabled = enabled, - singleLine = singleLine, - placeholder = { - Text( - text = placeHolderText, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onPrimaryContainer, - ) - }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - ), - keyboardOptions = keyboardOptions, - ) -} - -@Composable -fun LetroOutlinedTextField( - value: String, - onValueChange: (String) -> Unit, - modifier: Modifier = Modifier, - placeHolderText: String = "", - suffixText: String? = null, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - isError: Boolean = false, - maxLines: Int = 1, - singleLine: Boolean = true, -) { - OutlinedTextField( - modifier = modifier.fillMaxWidth(), - value = value, - onValueChange = onValueChange, - shape = RoundedCornerShape(4.dp), - textStyle = MaterialTheme.typography.bodyLarge, - placeholder = { - Text( - text = placeHolderText, - style = MaterialTheme.typography.bodyLarge, - ) - }, - suffix = { - if (suffixText != null) { - Text( - suffixText, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onPrimaryContainer, - ) - } - }, - keyboardOptions = keyboardOptions, - maxLines = maxLines, - singleLine = singleLine, - isError = isError, - ) -} - -@Composable -fun HyperlinkText( - fullText: String, - hyperLinks: Map, - modifier: Modifier = Modifier, - textColor: Color = MaterialTheme.colorScheme.onSurface, - linkTextColor: Color = MaterialTheme.colorScheme.primary, - linkTextFontWeight: FontWeight = FontWeight.Normal, - linkTextDecoration: TextDecoration = TextDecoration.None, - fontSize: TextUnit = TextUnit.Unspecified, -) { - val annotatedString = buildAnnotatedString { - append(fullText) - addStyle( - style = SpanStyle( - fontSize = fontSize, - color = textColor, - ), - start = 0, - end = fullText.length, - ) - for ((key, value) in hyperLinks) { - val startIndex = fullText.indexOf(key) - val endIndex = startIndex + key.length - addStyle( - style = SpanStyle( - color = linkTextColor, - fontSize = fontSize, - fontWeight = linkTextFontWeight, - textDecoration = linkTextDecoration, - ), - start = startIndex, - end = endIndex, - ) - addStringAnnotation( - tag = "URL", - annotation = value, - start = startIndex, - end = endIndex, - ) - } - } - - val uriHandler = LocalUriHandler.current - - ClickableText( - modifier = modifier, - text = annotatedString, - onClick = { - annotatedString - .getStringAnnotations("URL", it, it) - .firstOrNull()?.let { stringAnnotation -> - uriHandler.openUri(stringAnnotation.item) - } - }, - ) -} - @Preview(showBackground = true) @Composable fun CustomViewsPreview() { diff --git a/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt b/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt index e59d18ef..1e57568e 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt @@ -1,5 +1,6 @@ package tech.relaycorp.letro.ui.navigation +import android.util.Log import androidx.compose.foundation.layout.Column import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -12,8 +13,10 @@ import androidx.compose.runtime.setValue import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.navigation.NavHostController +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.navArgument import com.google.accompanist.systemuicontroller.SystemUiController import com.google.accompanist.systemuicontroller.rememberSystemUiController import tech.relaycorp.letro.R @@ -22,6 +25,8 @@ import tech.relaycorp.letro.main.MainViewModel import tech.relaycorp.letro.onboarding.actionTaking.ActionTakingScreen import tech.relaycorp.letro.onboarding.actionTaking.ActionTakingScreenUIStateModel import tech.relaycorp.letro.onboarding.registration.ui.RegistrationScreen +import tech.relaycorp.letro.pairing.PairWithOthersViewModel +import tech.relaycorp.letro.pairing.ui.PairWithOthersScreen import tech.relaycorp.letro.ui.common.LetroTopBar import tech.relaycorp.letro.ui.common.SplashScreen import tech.relaycorp.letro.ui.theme.LetroColor @@ -42,6 +47,7 @@ fun LetroNavHost( LaunchedEffect(navController) { navController.currentBackStackEntryFlow.collect { backStackEntry -> currentRoute = backStackEntry.destination.route.toRoute() + Log.d("LetroNavHost", "New route: $currentRoute") } } @@ -98,8 +104,12 @@ fun LetroNavHost( actionTakingScreenUIStateModel = ActionTakingScreenUIStateModel.NoContacts( title = R.string.onboarding_account_confirmation, image = R.drawable.account_created, - onPairWithPeople = { /* TODO */ }, - onShareId = { mainViewModel.onShareIdClick() }, + onPairWithOthersClick = { + navController.navigate("${Route.PairWithOthers.name}/${uiState.currentAccount}") + }, + onShareId = { + mainViewModel.onShareIdClick() + }, ), ) } @@ -109,8 +119,12 @@ fun LetroNavHost( title = null, message = R.string.no_contacts_text, image = R.drawable.no_contacts_image, - onPairWithPeople = { /* TODO */ }, - onShareId = { mainViewModel.onShareIdClick() }, + onPairWithOthersClick = { + navController.navigate("${Route.PairWithOthers.name}/${uiState.currentAccount}") + }, + onShareId = { + mainViewModel.onShareIdClick() + }, ), ) } @@ -126,6 +140,21 @@ fun LetroNavHost( ), ) } + composable( + route = "${Route.PairWithOthers.name}/{${PairWithOthersViewModel.KEY_CURRENT_ACCOUNT_ID}}", + arguments = listOf( + navArgument(PairWithOthersViewModel.KEY_CURRENT_ACCOUNT_ID) { + type = NavType.StringType + nullable = true + }, + ), + ) { + PairWithOthersScreen( + onBackClick = { + navController.popBackStack() + }, + ) + } } } } diff --git a/app/src/main/java/tech/relaycorp/letro/ui/navigation/Route.kt b/app/src/main/java/tech/relaycorp/letro/ui/navigation/Route.kt index 532f739a..d0adb8bd 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/navigation/Route.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/navigation/Route.kt @@ -1,5 +1,10 @@ package tech.relaycorp.letro.ui.navigation +/** + * Class which contains all possible routes + * + * NOTE: the route name must end with _route suffix + */ sealed class Route( val name: String, val showTopBar: Boolean = true, @@ -7,40 +12,46 @@ sealed class Route( ) { object Splash : Route( - name = "splash", + name = "splash_route", showTopBar = false, ) object Registration : Route( - name = "registration", + name = "registration_route", showTopBar = false, ) object AwalaNotInstalled : Route( - name = "awala_not_installed", + name = "awala_not_installed_route", showTopBar = false, ) object RegistrationProcessWaiting : Route( - name = "registration_waiting", + name = "registration_waiting_route", showTopBar = true, isStatusBarPrimaryColor = true, ) object NoContacts : Route( - name = "no_contacts", + name = "no_contacts_route", showTopBar = true, isStatusBarPrimaryColor = true, ) object WelcomeToLetro : Route( - name = "welcome_to_letro", + name = "welcome_to_letro_route", showTopBar = true, isStatusBarPrimaryColor = true, ) object PairingRequestSent : Route( - name = "pairing_request_sent", + name = "pairing_request_sent_route", + showTopBar = true, + isStatusBarPrimaryColor = true, + ) + + object PairWithOthers : Route( + name = "pair_with_others_route", showTopBar = true, isStatusBarPrimaryColor = true, ) @@ -48,14 +59,15 @@ sealed class Route( fun String?.toRoute(): Route { this?.let { - return when (it) { - Route.Registration.name -> Route.Registration - Route.AwalaNotInstalled.name -> Route.AwalaNotInstalled - Route.RegistrationProcessWaiting.name -> Route.RegistrationProcessWaiting - Route.WelcomeToLetro.name -> Route.WelcomeToLetro - Route.NoContacts.name -> Route.NoContacts - Route.PairingRequestSent.name -> Route.PairingRequestSent - Route.Splash.name -> Route.Splash + return when { + it.startsWith(Route.Splash.name) -> Route.Splash + it.startsWith(Route.AwalaNotInstalled.name) -> Route.AwalaNotInstalled + it.startsWith(Route.Registration.name) -> Route.Registration + it.startsWith(Route.RegistrationProcessWaiting.name) -> Route.RegistrationProcessWaiting + it.startsWith(Route.WelcomeToLetro.name) -> Route.WelcomeToLetro + it.startsWith(Route.NoContacts.name) -> Route.NoContacts + it.startsWith(Route.PairWithOthers.name) -> Route.PairWithOthers + it.startsWith(Route.PairingRequestSent.name) -> Route.PairingRequestSent else -> throw IllegalArgumentException("Define the Route by the name of the Route $it") } } diff --git a/app/src/main/res/drawable-v24/arrow_back.xml b/app/src/main/res/drawable-v24/arrow_back.xml new file mode 100644 index 00000000..499be30c --- /dev/null +++ b/app/src/main/res/drawable-v24/arrow_back.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/arrow_back.xml b/app/src/main/res/drawable/arrow_back.xml new file mode 100644 index 00000000..af2a6ca8 --- /dev/null +++ b/app/src/main/res/drawable/arrow_back.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7a515782..14049a94 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,6 +26,9 @@ You’ll receive a notification once you and %s are connected. Got it + Name (optional) + Request pairing + Hold tight! We are creating your account… @@ -35,8 +38,16 @@ There is no app to install Awala There is no app to share your id + You already tried to connect with them, but we can send another request if you want. + You two are already connected. + Your pairing with this user is already under way. + Join me on Letro: https://letro.app/connect/#u=%1$s + Go back + + james.bond@cuppa.uk + James Bond https://letro.app/en/terms \ No newline at end of file