diff --git a/.github/workflows/build-upload-android-alpha.yml b/.github/workflows/build-fc-upload-android-internal.yml similarity index 75% rename from .github/workflows/build-upload-android-alpha.yml rename to .github/workflows/build-fc-upload-android-internal.yml index 3c239aee6..ca71c0098 100644 --- a/.github/workflows/build-upload-android-alpha.yml +++ b/.github/workflows/build-fc-upload-android-internal.yml @@ -1,17 +1,13 @@ -name: Android Build and Deploy (Alpha) +name: Flipchat Build and Deploy (Internal) env: # The name of the main module repository - main_project_module: app + main_project_module: flipchatApp # The name of the Play Store - playstore_name: Code Wallet + playstore_name: Flipchat on: - push: - tags: - - 'v*' - # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -42,8 +38,8 @@ jobs: id: google_services_json_file with: fileName: google-services.json - fileDir: ./app/src - encodedString: ${{ secrets.GOOGLE_SERVICES_JSON }} + fileDir: ./flipchatApp/src + encodedString: ${{ secrets.FLIPCHAT_GOOGLE_SERVICES_JSON }} - name: Decode Service Account Key JSON file uses: timheuer/base64-to-file@v1 @@ -63,11 +59,11 @@ jobs: uses: timheuer/base64-to-file@v1 with: fileName: key - fileDir: ./app/key + fileDir: ./flipchatApp/key encodedString: ${{ secrets.UPLOAD_KEY_STORE }} - name: Setup BugSnag API Key - run: echo BUGSNAG_API_KEY=\"${{ secrets.BUGSNAG_API_KEY }}\" > ./local.properties + run: echo BUGSNAG_API_KEY=\"${{ secrets.FLIPCHAT_BUGSNAG_API_KEY }}\" > ./local.properties - name: Setup Fingerprint API Key run: echo FINGERPRINT_API_KEY=${{ secrets.FINGERPRINT_API_KEY }} >> ./local.properties @@ -79,13 +75,13 @@ jobs: run: echo KADO_API_KEY=\"${{ secrets.KADO_API_KEY }}\" >> ./local.properties - name: Setup Mixpanel API Key - run: echo MIXPANEL_API_KEY=\"${{ secrets.MIXPANEL_API_KEY }}\" >> ./local.properties + run: echo MIXPANEL_API_KEY=\"${{ secrets.FLIPCHAT_MIXPANEL_API_KEY }}\" >> ./local.properties - name: Run tests - run: bundle exec fastlane android test + run: bundle exec fastlane android fc_tests - name: Build & deploy Android release - run: bundle exec fastlane android deploy_alpha + run: bundle exec fastlane android deploy_fc_internal env: STORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.KEY_ALIAS}} @@ -93,8 +89,10 @@ jobs: SERVICE_ACCOUNT_KEY_JSON: ${{ steps.service_account_json_file.outputs.filePath }} - name: Upload build artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 + if: always() with: name: assets path: | - ${{ github.workspace }}/app/build/outputs/bundle/release + ${{ github.workspace }}/flipchatApp/build/outputs/bundle/release + ${{ github.workspace }}/flipchatApp/build/outputs/apk/release diff --git a/.github/workflows/build-upload-android-internal.yml b/.github/workflows/build-upload-android-internal.yml index 8e5d87e4f..a28897a8c 100644 --- a/.github/workflows/build-upload-android-internal.yml +++ b/.github/workflows/build-upload-android-internal.yml @@ -1,4 +1,4 @@ -name: Android Build and Deploy (Internal) +name: Code Build and Deploy (Internal) env: # The name of the main module repository @@ -78,10 +78,10 @@ jobs: run: echo MIXPANEL_API_KEY=\"${{ secrets.MIXPANEL_API_KEY }}\" >> ./local.properties - name: Run tests - run: bundle exec fastlane android test + run: bundle exec fastlane android code_tests - name: Build & deploy Android release - run: bundle exec fastlane android deploy_internal + run: bundle exec fastlane android deploy_code_internal env: STORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.KEY_ALIAS}} @@ -90,6 +90,7 @@ jobs: - name: Upload build artifacts uses: actions/upload-artifact@v4 + if: always() with: name: assets path: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e47e798e3..665edf16d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,9 @@ env: JAVA_VERSION: 17 jobs: - unit-tests: - name: Unit tests + code-tests: + name: Run Code Tests runs-on: ubuntu-latest - timeout-minutes: 60 steps: - uses: actions/checkout@master @@ -38,7 +37,7 @@ jobs: fileName: google-services.json fileDir: ./app/src encodedString: ${{ secrets.GOOGLE_SERVICES_JSON }} - + - name: Setup BugSnag API Key run: echo BUGSNAG_API_KEY=\"${{ secrets.BUGSNAG_API_KEY }}\" > ./local.properties @@ -54,5 +53,50 @@ jobs: - name: Setup Mixpanel API Key run: echo MIXPANEL_API_KEY=\"${{ secrets.MIXPANEL_API_KEY }}\" >> ./local.properties - - name: Run tests - run: bundle exec fastlane android test + - name: Run Code tests + run: bundle exec fastlane android code_tests + + fc-tests: + name: Run Flipchat Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + + - name: Setup Java env + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'corretto' + cache: 'gradle' + + - name: Setup Ruby env + uses: ruby/setup-ruby@v1 + with: + ruby-version: 2.7.2 + bundler-cache: true + + - name: Decode Google Services JSON file + uses: timheuer/base64-to-file@v1 + id: google_services_json_file + with: + fileName: google-services.json + fileDir: ./flipchatApp/src + encodedString: ${{ secrets.FLIPCHAT_GOOGLE_SERVICES_JSON }} + + - name: Setup BugSnag API Key + run: echo BUGSNAG_API_KEY=\"${{ secrets.FLIPCHAT_BUGSNAG_API_KEY }}\" > ./local.properties + + - name: Setup Fingerprint API Key + run: echo FINGERPRINT_API_KEY=${{ secrets.FINGERPRINT_API_KEY }} >> ./local.properties + + - name: Setup Google Cloud Project Number + run: echo GOOGLE_CLOUD_PROJECT_NUMBER=${{ secrets.GOOGLE_CLOUD_PROJECT_NUMBER }} >> ./local.properties + + - name: Setup Kado API Key + run: echo KADO_API_KEY=\"${{ secrets.KADO_API_KEY }}\" >> ./local.properties + + - name: Setup Mixpanel API Key + run: echo MIXPANEL_API_KEY=\"${{ secrets.FLIPCHAT_MIXPANEL_API_KEY }}\" >> ./local.properties + + - name: Run Code tests + run: bundle exec fastlane android fc_tests diff --git a/Gemfile b/Gemfile index 7a118b49b..cdd3a6b34 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,6 @@ source "https://rubygems.org" gem "fastlane" + +plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') +eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/Gemfile.lock b/Gemfile.lock index ddced0c39..b4e1188f6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -105,6 +105,7 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.3.0) xcpretty-travis-formatter (>= 0.0.3) + fastlane-plugin-bundletool (1.0.12) gh_inspector (1.1.3) google-apis-androidpublisher_v3 (0.37.0) google-apis-core (>= 0.11.0, < 2.a) @@ -209,11 +210,13 @@ GEM xcpretty (~> 0.2, >= 0.0.7) PLATFORMS + arm64-darwin-24 universal-darwin-21 x86_64-linux DEPENDENCIES fastlane + fastlane-plugin-bundletool BUNDLED WITH 2.2.6 diff --git a/api/src/main/AndroidManifest.xml b/api/src/main/AndroidManifest.xml deleted file mode 100644 index 9091cdd71..000000000 --- a/api/src/main/AndroidManifest.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - diff --git a/api/src/main/java/com/getcode/crypt/KeyAccount.kt b/api/src/main/java/com/getcode/crypt/KeyAccount.kt deleted file mode 100644 index 3582bb008..000000000 --- a/api/src/main/java/com/getcode/crypt/KeyAccount.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.getcode.crypt - -import android.content.Context -import com.getcode.solana.organizer.Organizer -import com.getcode.solana.keys.PublicKey - -class KeyAccount( - val mnemonic: MnemonicPhrase, - val derivedKey: DerivedKey, - val tokenAccount: PublicKey -) { - companion object { - fun newInstance( - mnemonic: MnemonicPhrase, - derivedKey: DerivedKey, - tokenAccount: PublicKey - ): KeyAccount { - return KeyAccount( - mnemonic = mnemonic, - derivedKey = derivedKey, - tokenAccount = tokenAccount - ) - } - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/db/ConversationDao.kt b/api/src/main/java/com/getcode/db/ConversationDao.kt deleted file mode 100644 index ab0f87182..000000000 --- a/api/src/main/java/com/getcode/db/ConversationDao.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.getcode.db - -import androidx.paging.PagingSource -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.RewriteQueriesToDropUnusedColumns -import com.getcode.model.Conversation -import com.getcode.model.ConversationWithLastPointers -import com.getcode.model.ID -import com.getcode.network.repository.base58 -import kotlinx.coroutines.flow.Flow - -@Dao -interface ConversationDao { - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertConversations(vararg conversation: Conversation) - - @RewriteQueriesToDropUnusedColumns - @Query("SELECT * FROM conversations") - fun observeConversations(): PagingSource - - @RewriteQueriesToDropUnusedColumns - @Query("SELECT * FROM conversations LEFT JOIN conversation_pointers ON conversations.idBase58 = conversation_pointers.conversationIdBase58 WHERE conversations.idBase58 = :id") - fun observeConversation(id: String): Flow - - fun observeConversation(id: ID): Flow { - return observeConversation(id.base58) - } - - @RewriteQueriesToDropUnusedColumns - @Query("SELECT * FROM conversations LEFT JOIN conversation_pointers ON conversations.idBase58 = conversation_pointers.conversationIdBase58 WHERE conversations.idBase58 = :id") - suspend fun findConversation(id: String): ConversationWithLastPointers? - - suspend fun findConversation(id: ID): ConversationWithLastPointers? { - return findConversation(id.base58) - } - - @Query("SELECT * FROM conversations") - suspend fun queryConversations(): List - - @Query("SELECT EXISTS (SELECT 1 FROM messages WHERE conversationIdBase58 = :conversationId)") - suspend fun hasInteracted(conversationId: String): Boolean - - suspend fun hasInteracted(conversationId: ID): Boolean { - return hasInteracted(conversationId.base58) - } - - @Query("UPDATE conversations SET hasRevealedIdentity = 1 WHERE idBase58 = :conversationId") - suspend fun revealIdentity(conversationId: String) - - suspend fun revealIdentity(conversationId: ID) { - revealIdentity(conversationId.base58) - } - -// @Query("SELECT EXISTS (SELECT * FROM messages WHERE conversationIdBase58 = :messageId AND content LIKE '%4|%')") -// suspend fun hasRevealedIdentity(messageId: String): Boolean -// -// suspend fun hasRevealedIdentity(messageId: ID): Boolean { -// return hasRevealedIdentity(messageId.base58) -// } - - @Delete - fun deleteConversation(conversation: Conversation) - - @Query("DELETE FROM conversations WHERE idBase58 = :id") - suspend fun deleteConversationById(id: String) - - suspend fun deleteConversationById(id: ID) { - deleteConversationById(id.base58) - } - - @Query("DELETE FROM conversations WHERE idBase58 NOT IN (:chatIds)") - suspend fun purgeConversationsNotInByString(chatIds: List) - suspend fun purgeConversationsNotIn(chatIds: List) { - purgeConversationsNotInByString(chatIds.map { it.base58 }) - } - - @Query("DELETE FROM conversations") - fun clearConversations() -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/db/ConversationIntentMappingDao.kt b/api/src/main/java/com/getcode/db/ConversationIntentMappingDao.kt deleted file mode 100644 index 15e8ae7f0..000000000 --- a/api/src/main/java/com/getcode/db/ConversationIntentMappingDao.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.getcode.db - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import com.getcode.model.ConversationIntentIdReference -import com.getcode.model.ID -import com.getcode.network.repository.base58 - -@Dao -interface ConversationIntentMappingDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(mapping: ConversationIntentIdReference) - - @Query("SELECT * FROM conversation_intent_id_mapping WHERE intentIdBase58 = :id") - suspend fun conversationIdByReference(id: String): ConversationIntentIdReference? - - suspend fun conversationIdByReference(id: ID): ConversationIntentIdReference? { - return conversationIdByReference(id.base58) - } - - @Query("DELETE FROM conversation_intent_id_mapping WHERE conversationIdBase58 NOT IN (:chatIds)") - suspend fun purgeMappingNoLongerNeededByString(chatIds: List) - - suspend fun purgeMappingNoLongerNeeded(chatIds: List) { - purgeMappingNoLongerNeededByString(chatIds.map { it.base58 }) - } - - @Query("DELETE FROM conversation_intent_id_mapping") - suspend fun clearMapping() -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/db/ConversationMessageDao.kt b/api/src/main/java/com/getcode/db/ConversationMessageDao.kt deleted file mode 100644 index ed2bf2b9e..000000000 --- a/api/src/main/java/com/getcode/db/ConversationMessageDao.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.getcode.db - -import androidx.paging.PagingSource -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.RewriteQueriesToDropUnusedColumns -import androidx.room.Transaction -import com.getcode.model.ConversationMessage -import com.getcode.model.ConversationMessageContent -import com.getcode.model.ConversationMessageWithContent -import com.getcode.model.ID -import com.getcode.model.chat.MessageContent -import com.getcode.network.repository.base58 - -@Dao -interface ConversationMessageDao { - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertMessages(vararg message: ConversationMessage) - - suspend fun upsertMessages(message: List) { - upsertMessages(*message.toTypedArray()) - } - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertMessageContent(vararg content: ConversationMessageContent) - - suspend fun upsertMessageContent(messageId: ID, contents: List) { - val contentList = contents.map { - ConversationMessageContent(messageId.base58, it) - } - upsertMessageContent(*contentList.toTypedArray()) - } - - @Transaction - suspend fun upsertMessagesWithContent(vararg message: ConversationMessageWithContent) { - message.onEach { - upsertMessages(it.message) - upsertMessageContent(it.message.id, it.contents) - } - } - - @Transaction - suspend fun upsertMessagesWithContent(messages: List) { - messages.onEach { - upsertMessages(it.message) - upsertMessageContent(it.message.id, it.contents) - } - } - - @RewriteQueriesToDropUnusedColumns - @Transaction - @Query("SELECT * FROM messages JOIN message_contents ON messages.idBase58 = message_contents.messageIdBase58 WHERE conversationIdBase58 = :id ORDER BY dateMillis DESC") - fun observeConversationMessages(id: String): PagingSource - - fun observeConversationMessages(id: ID): PagingSource { - return observeConversationMessages(id.base58) - } - - @Query("SELECT * FROM messages WHERE conversationIdBase58 = :conversationId") - suspend fun queryMessages(conversationId: String): List - - suspend fun queryMessages(conversationId: ID): List { - return queryMessages(conversationId.base58) - } - - @Query("SELECT * FROM messages WHERE conversationIdBase58 = :conversationId ORDER BY dateMillis DESC LIMIT 1") - suspend fun getNewestMessage(conversationId: String): ConversationMessage? - - suspend fun getNewestMessage(conversationId: ID): ConversationMessage? { - return getNewestMessage(conversationId.base58) - } - - @Query("DELETE FROM messages WHERE conversationIdBase58 = :conversationId") - suspend fun deleteForConversation(conversationId: String) - - suspend fun deleteForConversation(conversationId: ID) { - deleteForConversation(conversationId.base58) - } - - @Query("DELETE FROM messages WHERE conversationIdBase58 NOT IN (:chatIds)") - suspend fun purgeMessagesNotInByString(chatIds: List) - - suspend fun purgeMessagesNotIn(chatIds: List) { - purgeMessagesNotInByString(chatIds.map { it.base58 }) - } - - @Query("DELETE FROM messages") - fun clearMessages() -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/manager/BottomBarManager.kt b/api/src/main/java/com/getcode/manager/BottomBarManager.kt deleted file mode 100644 index 30f31954d..000000000 --- a/api/src/main/java/com/getcode/manager/BottomBarManager.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.getcode.manager - -import kotlinx.coroutines.flow.* -import java.util.* - - -/** - * Class responsible for managing BottomBar messages to show on the screen - */ -object BottomBarManager { - data class BottomBarMessage( - val title: String, - val subtitle: String = "", - val positiveText: String, - val negativeText: String, - val tertiaryText: String? = null, - val onPositive: () -> Unit, - val onNegative: () -> Unit = {}, - val onTertiary: () -> Unit = {}, - val onClose: (actionType: BottomBarActionType?) -> Unit = {}, - val type: BottomBarMessageType = BottomBarMessageType.DEFAULT, - val isDismissible: Boolean = true, - val timeoutSeconds: Int? = null, - val id: Long = UUID.randomUUID().mostSignificantBits, - ) - - private val _messages: MutableStateFlow> = MutableStateFlow( - listOf() - ) - val messages: StateFlow> get() = _messages.asStateFlow() - - fun showMessage(bottomBarMessage: BottomBarMessage) { - _messages.update { currentMessages -> - currentMessages + bottomBarMessage - } - } - - fun setMessageShown(messageId: Long) { - _messages.update { currentMessages -> - currentMessages.filterNot { it.id == messageId } - } - } - - fun clear() = _messages.update { listOf() } - - fun clearByType(type: BottomBarMessageType) = _messages.update { it.filterNot { m -> m.type == type } } - - enum class BottomBarMessageType { DEFAULT, REMOTE_SEND } - - enum class BottomBarActionType { - Positive, - Negative, - Tertiary - } - -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/mapper/ChatMemberMapper.kt b/api/src/main/java/com/getcode/mapper/ChatMemberMapper.kt deleted file mode 100644 index 910f12f3a..000000000 --- a/api/src/main/java/com/getcode/mapper/ChatMemberMapper.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.getcode.mapper - -import com.codeinc.gen.chat.v2.ChatService -import com.getcode.model.chat.ChatMember -import com.getcode.model.chat.Identity -import com.getcode.model.chat.Pointer -import com.getcode.model.uuid -import javax.inject.Inject - -class ChatMemberMapper @Inject constructor(): Mapper { - override fun map(from: ChatService.ChatMember): ChatMember? { - return ChatMember( - id = from.memberId.value.toByteArray().toList().uuid ?: return null, - identity = runCatching { Identity(from.identity) }.getOrNull(), - isMuted = from.isMuted, - isSelf = from.isSelf, - isSubscribed = from.isSubscribed, - numUnread = from.numUnread, - pointers = from.pointersList.map { Pointer(it) } - ) - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/mapper/ChatMessageV1Mapper.kt b/api/src/main/java/com/getcode/mapper/ChatMessageV1Mapper.kt deleted file mode 100644 index e31dd12a1..000000000 --- a/api/src/main/java/com/getcode/mapper/ChatMessageV1Mapper.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.getcode.mapper - - -import com.getcode.model.MessageStatus -import com.getcode.model.chat.Chat -import com.getcode.model.chat.ChatMessage -import com.getcode.model.chat.MessageContent -import javax.inject.Inject -import com.codeinc.gen.chat.v1.ChatService.ChatMessage as ApiChatMessage -import com.getcode.model.chat.ChatMessage as DomainChatMessage - -@Deprecated("Replace by V2") -class ChatMessageV1Mapper @Inject constructor( -): Mapper, DomainChatMessage> { - override fun map(from: Pair): ChatMessage { - val (chat, message) = from - - val messageId = message.messageId.value.toList() - val contents = message.contentList.mapNotNull { MessageContent.fromV1(messageId, it) } - val isFromSelf = contents.firstOrNull { it.isFromSelf } != null - - return ChatMessage( - id = messageId, - senderId = null, - isFromSelf = isFromSelf, - cursor = message.cursor.value.toList(), - dateMillis = message.ts.seconds * 1_000L, - contents = contents, -// status = if (isFromSelf) MessageStatus.Sent else MessageStatus.Incoming - ) - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/mapper/ChatMessageV2Mapper.kt b/api/src/main/java/com/getcode/mapper/ChatMessageV2Mapper.kt deleted file mode 100644 index a8f79c459..000000000 --- a/api/src/main/java/com/getcode/mapper/ChatMessageV2Mapper.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.getcode.mapper - - -import com.getcode.model.chat.Chat -import com.getcode.model.chat.ChatMessage -import com.getcode.model.chat.MessageContent -import com.getcode.model.uuid -import javax.inject.Inject -import com.codeinc.gen.chat.v2.ChatService.ChatMessage as ApiChatMessage -import com.getcode.model.chat.ChatMessage as DomainChatMessage - - -class ChatMessageV2Mapper @Inject constructor( -): Mapper, DomainChatMessage> { - override fun map(from: Pair): ChatMessage { - val (chat, message) = from - - val messageId = message.messageId.value.toByteArray().toList() - val messageSenderId = message.senderId.value.toByteArray().toList().uuid - val selfMember = chat.members.firstOrNull { it.isSelf } - val isFromSelf = selfMember?.id == messageSenderId - - return ChatMessage( - id = messageId, - senderId = messageSenderId, - isFromSelf = isFromSelf, - cursor = message.cursor.value.toList(), - dateMillis = message.ts.seconds * 1_000L, - contents = message.contentList.mapNotNull { MessageContent(it, isFromSelf) }, - ) - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/mapper/ChatMetadataV2Mapper.kt b/api/src/main/java/com/getcode/mapper/ChatMetadataV2Mapper.kt deleted file mode 100644 index 913aa05af..000000000 --- a/api/src/main/java/com/getcode/mapper/ChatMetadataV2Mapper.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.getcode.mapper - -import com.codeinc.gen.chat.v2.ChatService -import com.getcode.model.chat.Chat -import com.getcode.model.chat.ChatType -import com.getcode.model.chat.Title -import javax.inject.Inject - -class ChatMetadataV2Mapper @Inject constructor( - private val chatMemberMapper: ChatMemberMapper, -) : Mapper { - override fun map(from: ChatService.ChatMetadata): Chat { - return Chat( - id = from.chatId.value.toByteArray().toList(), - title = Title.Localized(from.title), - cursor = from.cursor.value.toByteArray().toList(), - canMute = from.canMute, - canUnsubscribe = from.canUnsubscribe, - members = from.membersList.mapNotNull { chatMemberMapper.map(it) }, - type = ChatType(from.type), - messages = emptyList(), - ) - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/mapper/ConversationMapper.kt b/api/src/main/java/com/getcode/mapper/ConversationMapper.kt deleted file mode 100644 index 548c58dc9..000000000 --- a/api/src/main/java/com/getcode/mapper/ConversationMapper.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.getcode.mapper - -import com.getcode.model.Conversation -import com.getcode.model.chat.Chat -import com.getcode.model.chat.ChatType -import com.getcode.model.chat.self -import com.getcode.network.localized -import com.getcode.network.repository.base58 -import com.getcode.util.resources.ResourceHelper -import javax.inject.Inject - -class ConversationMapper @Inject constructor( - private val resources: ResourceHelper, -) : Mapper { - override fun map(from: Chat): Conversation { - - val self = from.self?.identity - - return Conversation( - idBase58 = from.id.base58, - title = when (from.type) { - ChatType.Unknown, - ChatType.Notification -> from.title.localized(resources) - ChatType.TwoWay -> null - }, - hasRevealedIdentity = self != null, - members = from.members.map { it }, - lastActivity = null, // TODO: ? - ) - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/mapper/ConversationMessageMapper.kt b/api/src/main/java/com/getcode/mapper/ConversationMessageMapper.kt deleted file mode 100644 index 128740ad0..000000000 --- a/api/src/main/java/com/getcode/mapper/ConversationMessageMapper.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.getcode.mapper - -import com.getcode.model.ConversationMessage -import com.getcode.model.ID -import com.getcode.model.chat.ChatMessage -import com.getcode.network.repository.base58 -import javax.inject.Inject - -class ConversationMessageMapper @Inject constructor() : - Mapper, ConversationMessage> { - override fun map(from: Pair): ConversationMessage { - val (conversationId, message) = from - - return ConversationMessage( - idBase58 = message.id.base58, - cursorBase58 = message.cursor.base58, - conversationIdBase58 = conversationId.base58, - dateMillis = message.dateMillis, -// status = message.status - ) - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/mapper/ConversationMessageWithContentMapper.kt b/api/src/main/java/com/getcode/mapper/ConversationMessageWithContentMapper.kt deleted file mode 100644 index fb28577fd..000000000 --- a/api/src/main/java/com/getcode/mapper/ConversationMessageWithContentMapper.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.getcode.mapper - -import com.getcode.model.ConversationMessageContent -import com.getcode.model.ConversationMessageWithContent -import com.getcode.model.ID -import com.getcode.model.chat.ChatMessage -import com.getcode.model.chat.MessageContent -import javax.inject.Inject - -class ConversationMessageWithContentMapper @Inject constructor( - private val messageMapper: ConversationMessageMapper, -): Mapper, ConversationMessageWithContent> { - override fun map(from: Pair): ConversationMessageWithContent { - val (_, message) = from - val conversationMessage = messageMapper.map(from) - - return ConversationMessageWithContent( - message = conversationMessage, - contents = message.contents - ) - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/mapper/PointerMapper.kt b/api/src/main/java/com/getcode/mapper/PointerMapper.kt deleted file mode 100644 index b53c30c2c..000000000 --- a/api/src/main/java/com/getcode/mapper/PointerMapper.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.getcode.mapper - -import com.codeinc.gen.chat.v2.ChatService -import com.getcode.model.ID -import com.getcode.model.MessageStatus -import com.getcode.model.chat.PointerV2 -import com.getcode.model.uuid -import com.getcode.utils.timestamp -import java.util.UUID -import javax.inject.Inject - -data class PointerStatus( - val messageId: UUID, - val memberId: UUID, - val messageStatus: MessageStatus, -) { - - val timestamp: Long? - get() = messageId.timestamp -} - -class PointerMapper @Inject constructor(): Mapper { - override fun map(from: PointerV2): PointerStatus? { - val status = when (from.type) { - ChatService.PointerType.SENT -> MessageStatus.Sent - ChatService.PointerType.DELIVERED -> MessageStatus.Delivered - ChatService.PointerType.READ -> MessageStatus.Read - else -> MessageStatus.Unknown - } - - val messageId = from.value.value.toByteArray().toList().uuid ?: return null - val memberId = from.memberId.value.toByteArray().toList().uuid ?: return null - - return PointerStatus( - messageId = messageId, - memberId = memberId, - messageStatus = status, - ) - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/Conversation.kt b/api/src/main/java/com/getcode/model/Conversation.kt deleted file mode 100644 index 29c9107d9..000000000 --- a/api/src/main/java/com/getcode/model/Conversation.kt +++ /dev/null @@ -1,147 +0,0 @@ -package com.getcode.model - -import androidx.room.ColumnInfo -import androidx.room.Embedded -import androidx.room.Entity -import androidx.room.Ignore -import androidx.room.PrimaryKey -import androidx.room.Relation -import com.getcode.model.chat.ChatMember -import com.getcode.model.chat.MessageContent -import com.getcode.utils.serializer.MessageContentSerializer -import com.getcode.vendor.Base58 -import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient -import java.util.UUID - -@Serializable -@Entity(tableName = "conversations") -data class Conversation( - @PrimaryKey - val idBase58: String, - val title: String?, - val hasRevealedIdentity: Boolean, - @ColumnInfo(defaultValue = "") - val members: List, - val lastActivity: Long?, -) { - @Ignore - val id: ID = Base58.decode(idBase58).toList() - - val name: String? - get() = nonSelfMembers - .mapNotNull { it.identity?.username } - .joinToString() - .takeIf { it.isNotEmpty() } - - val nonSelfMembers: List - get() = members.filterNot { it.isSelf } - - override fun toString(): String { - return """ - { - id:${idBase58}, - title:$title, - hasRevealedIdentity:$hasRevealedIdentity, - members:${members.joinToString()} - } - """.trimIndent() - } -} - -@Serializable -@Entity(tableName = "conversation_pointers", primaryKeys = ["conversationIdBase58", "status"]) -data class ConversationPointerCrossRef( - val conversationIdBase58: String, - val messageIdString: String, - val status: MessageStatus, -) { - @Ignore - val conversationId: ID = Base58.decode(conversationIdBase58).toList() - - @Ignore - @Transient - val messageId: UUID = UUID.fromString(messageIdString) -} - -@Serializable -data class ConversationWithLastPointers( - @Embedded val conversation: Conversation, - @Relation( - parentColumn = "idBase58", - entityColumn = "conversationIdBase58", - entity = ConversationPointerCrossRef::class, - ) - val pointersCrossRef: List -) { - val pointers: Map - get() { - return pointersCrossRef - .associateBy { it.status } - .mapKeys { it.value.messageId } - .mapValues { it.value.status } - } -} - -@Serializable -@Entity(tableName = "messages") -data class ConversationMessage( - @PrimaryKey - val idBase58: String, - val cursorBase58: String, - val conversationIdBase58: String, - val dateMillis: Long, -) { - @Ignore - val id: ID = Base58.decode(idBase58).toList() - - @Ignore - val conversationId: ID = Base58.decode(conversationIdBase58).toList() - - @Ignore - val cursor: Cursor = Base58.decode(cursorBase58).toList() -} - -@Serializable -@Entity(tableName = "message_contents", primaryKeys = ["messageIdBase58", "content"]) -data class ConversationMessageContent( - val messageIdBase58: String, - val content: MessageContent -) - -data class ConversationMessageWithContent( - @Embedded val message: ConversationMessage, - @Relation( - parentColumn = "idBase58", - entityColumn = "messageIdBase58", - entity = ConversationMessageContent::class, - projection = ["content"] - ) - val contents: List, -) - -@Entity(tableName = "conversation_intent_id_mapping") -data class ConversationIntentIdReference( - @PrimaryKey - val conversationIdBase58: String, - val intentIdBase58: String, -) { - @Ignore - val conversationId: ID = Base58.decode(conversationIdBase58).toList() - - @Ignore - val intentId: ID = Base58.decode(intentIdBase58).toList() -} - -enum class MessageStatus { - Sent, Delivered, Read, Unknown; - - fun isOutgoing() = when (this) { - Sent, - Delivered -> true - - else -> false - } - - fun isValid() = this != Unknown -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/Fiat.kt b/api/src/main/java/com/getcode/model/Fiat.kt deleted file mode 100644 index fc6926abb..000000000 --- a/api/src/main/java/com/getcode/model/Fiat.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.getcode.model - -import kotlinx.serialization.Serializable - -sealed interface Value - -@Serializable -data class Fiat( - val currency: CurrencyCode, - val amount: Double, -): Value { - - companion object { - fun fromString(currency: CurrencyCode, amountString: String): Fiat? { - val amount = amountString.toDoubleOrNull() ?: return null - return Fiat( - currency = currency, - amount = amount - ) - } - } -} - -sealed interface GenericAmount { - - val currencyCode: CurrencyCode - data class Exact(val amount: KinAmount): GenericAmount { - override val currencyCode: CurrencyCode = amount.rate.currency - } - data class Partial(val fiat: Fiat): GenericAmount { - override val currencyCode: CurrencyCode = fiat.currency - } - - fun amountUsing(rate: Rate): KinAmount { - return when (this) { - is Exact -> amount - is Partial -> KinAmount.fromFiatAmount(fiat.amount, rate) - } - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/KinAmount.kt b/api/src/main/java/com/getcode/model/KinAmount.kt deleted file mode 100644 index a777f7ee3..000000000 --- a/api/src/main/java/com/getcode/model/KinAmount.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.getcode.model - -import com.codeinc.gen.transaction.v2.TransactionService.ExchangeData -import com.getcode.model.Kin.Companion.fromKin -import com.getcode.utils.FormatUtils -import com.getcode.utils.serializer.KinQuarksSerializer -import com.getcode.utils.serializer.PublicKeyAsStringSerializer -import com.getcode.utils.serializer.RateAsStringSerializer -import kotlinx.serialization.Serializable - -@Serializable -data class KinAmount( - @Serializable(with = KinQuarksSerializer::class) - val kin: Kin, - val fiat: Double, - @Serializable(with = RateAsStringSerializer::class) - val rate: Rate -) { - fun truncating() = KinAmount( - kin = kin.toKinTruncating(), - fiat = fiat, - rate = rate - ) - - fun replacing(rate: Rate): KinAmount { - return newInstance(this.kin, rate) - } - - companion object { - val Zero = newInstance(0, Rate.oneToOne) - - fun newInstance(kin: Int, rate: Rate): KinAmount { - return newInstance(fromKin(kin), rate) - } - - fun newInstance(kin: Kin, rate: Rate): KinAmount { - return KinAmount( - kin = kin, - fiat = kin.toFiat(fx = rate.fx), - rate = rate - ) - } - - fun fromFiatAmount(kin: Kin, fiat: Double, fx: Double, currencyCode: CurrencyCode): KinAmount { - return KinAmount( - kin = kin.inflating(), - fiat = fiat, - rate = Rate( - fx = fx, - currency = currencyCode - ) - ) - } - - fun fromFiatAmount(fiat: Double, fx: Double, currencyCode: CurrencyCode): KinAmount { - return fromFiatAmount( - kin = Kin.fromFiat(fiat = fiat, fx = fx), - fiat = fiat, - fx = fx, - currencyCode = currencyCode - ) - } - - fun fromFiatAmount(fiat: Double, rate: Rate): KinAmount { - return fromFiatAmount( - kin = Kin.fromFiat(fiat = fiat, fx = rate.fx), - fiat = fiat, - fx = rate.fx, - currencyCode = rate.currency - ) - } - - fun fromFiatAmount(fiat: Fiat, rate: Rate): KinAmount { - return KinAmount( - kin = Kin.fromFiat(fiat = fiat.amount, fx = rate.fx), - fiat = fiat.amount, - rate = rate - ) - } - - fun fromProtoExchangeData(exchangeData: ExchangeData): KinAmount { - return fromFiatAmount( - kin = Kin(exchangeData.quarks), - fiat = exchangeData.nativeAmount, - fx = exchangeData.exchangeRate, - currencyCode = CurrencyCode.tryValueOf(exchangeData.currency)!! - ) - } - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/TwitterUser.kt b/api/src/main/java/com/getcode/model/TwitterUser.kt deleted file mode 100644 index 6d0318d9d..000000000 --- a/api/src/main/java/com/getcode/model/TwitterUser.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.getcode.model - -import android.webkit.MimeTypeMap -import com.codeinc.gen.user.v1.IdentityService -import com.codeinc.gen.user.v1.friendshipCostOrNull -import com.getcode.solana.keys.PublicKey -import com.getcode.utils.serializer.PublicKeyAsStringSerializer -import kotlinx.serialization.Serializable - -@Serializable -data class TwitterUser( - override val username: String, - @Serializable(with = PublicKeyAsStringSerializer::class) - override val tipAddress: PublicKey, - override val imageUrl: String?, - val displayName: String, - val followerCount: Int, - val verificationStatus: VerificationStatus, - override val costOfFriendship: Fiat, - override val isFriend: Boolean, - override val chatId: ID, -): SocialUser { - - override val platform: String = "X" - - override val imageUrlSanitized: String? - get() { - val url = imageUrl ?: return null - val extension = MimeTypeMap.getFileExtensionFromUrl(url) - val urlWithoutExtension = url.removeSuffix(extension) - val urlWithoutType = urlWithoutExtension.substringBeforeLast("_") - - return urlWithoutType.plus(".$extension") - } - - enum class VerificationStatus { - none, blue, business, government, unknown - } - - companion object { - fun invoke(proto: IdentityService.TwitterUser): TwitterUser? { - val avatarUrl = proto.profilePicUrl - - val tipAddress = runCatching { PublicKey.fromByteString(proto.tipAddress.value) }.getOrNull() ?: return null - - return TwitterUser( - username = proto.username, - displayName = proto.name, - imageUrl = avatarUrl, - followerCount = proto.followerCount, - tipAddress = tipAddress, - verificationStatus = VerificationStatus.entries.getOrNull(proto.verifiedTypeValue) ?: VerificationStatus.unknown, - costOfFriendship = proto.friendshipCostOrNull?.let { - val currency = CurrencyCode.tryValueOf(it.currency) ?: return@let null - Fiat(currency, it.nativeAmount) - } ?: Fiat(currency = CurrencyCode.USD, amount = 1.00), - isFriend = runCatching { proto.isFriend }.getOrNull() ?: false, - chatId = proto.friendChatId.value.toList() - ) - } - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/Username.kt b/api/src/main/java/com/getcode/model/Username.kt deleted file mode 100644 index 32a3b81eb..000000000 --- a/api/src/main/java/com/getcode/model/Username.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.getcode.model - -data class Username(val value: String): Value \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/chat/Chat.kt b/api/src/main/java/com/getcode/model/chat/Chat.kt deleted file mode 100644 index 21e624b66..000000000 --- a/api/src/main/java/com/getcode/model/chat/Chat.kt +++ /dev/null @@ -1,150 +0,0 @@ -package com.getcode.model.chat - -import com.getcode.model.Cursor -import com.getcode.model.ID -import kotlinx.serialization.Serializable -import java.util.UUID - -/** - * Chat domain model for On-Chain messaging. This serves as a reference to a collection of messages. - * - * @param id Unique chat identifier ([ID]) - * @param type The type of chat - * @param title The chat title, which will be localized by server when applicable - * @param members The members in this chat - * For [ChatType.Notification], this list has exactly 1 item - * For [ChatType.TwoWay], this list has exactly 2 items - * @param canMute Can the user mute this chat? - * @param canUnsubscribe Can the user unsubscribe from this chat? - * @param cursor [Cursor] value for this chat for reference in subsequent GetChatsRequest - * @param messages List of messages within this chat - */ -@Serializable -data class Chat( - val id: ID, - val type: ChatType, - val title: Title?, - val members: List = emptyList(), - private val _unreadCount: Int = 0, - private val _isMuted: Boolean = false, - val canMute: Boolean, - private val _isSubscribed: Boolean = false, - val canUnsubscribe: Boolean, - val cursor: Cursor, - val messages: List -) { - val imageData: Any? - get() { - return when (type) { - ChatType.Unknown -> id - ChatType.Notification -> id - ChatType.TwoWay -> { - members - .filterNot { it.isSelf } - .firstNotNullOfOrNull { - if (it.identity != null) { - it.identity.imageUrl.orEmpty() - } else { - it.id - } - } - } - } - } - - val unreadCount: Int - get() { - if (!isV2) return _unreadCount - - val self = members.firstOrNull { it.isSelf } ?: return 0 - return self.numUnread - } - - fun resetUnreadCount(): Chat { - if (!isV2) { - return copy(_unreadCount = 0) - } - - val self = members.firstOrNull { it.isSelf } ?: return this - val updatedSelf = self.copy(numUnread = 0) - val updatedMembers = members.map { - if (it.id == self.id) { - updatedSelf - } else { - it - } - } - return copy(members = updatedMembers) - } - - val isMuted: Boolean - get() { - if (!isV2) return _isMuted - - val self = members.firstOrNull { it.isSelf } ?: return false - return self.isMuted - } - - fun setMuteState(muted: Boolean): Chat { - if (!isV2) { - return copy(_isMuted = muted) - } - - val self = members.firstOrNull { it.isSelf } ?: return this - val updatedSelf = self.copy(isMuted = muted) - val updatedMembers = members.map { - if (it.id == self.id) { - updatedSelf - } else { - it - } - } - return copy(members = updatedMembers) - } - - val isSubscribed: Boolean - get() { - if (!isV2) return _isSubscribed - - val self = members.firstOrNull { it.isSelf } ?: return false - return self.isSubscribed - } - - fun setSubscriptionState(subscribed: Boolean): Chat { - if (!isV2) { - return copy(_isSubscribed = subscribed) - } - - val self = members.firstOrNull { it.isSelf } ?: return this - val updatedSelf = self.copy(isSubscribed = subscribed) - val updatedMembers = members.map { - if (it.id == self.id) { - updatedSelf - } else { - it - } - } - return copy(members = updatedMembers) - } - - val newestMessage: ChatMessage? - get() = messages.maxByOrNull { it.dateMillis } - - val lastMessageMillis: Long? - get() = newestMessage?.dateMillis -} - -val Chat.isV2: Boolean - get() = members.isNotEmpty() - -val Chat.isNotification: Boolean - get() = type == ChatType.Notification - -val Chat.isConversation: Boolean - get() = type == ChatType.TwoWay - -val Chat.self: ChatMember? - get() = members.firstOrNull { it.isSelf } - -val Chat.selfId: UUID? - get() = self?.id diff --git a/api/src/main/java/com/getcode/model/chat/ChatMember.kt b/api/src/main/java/com/getcode/model/chat/ChatMember.kt deleted file mode 100644 index d49792f80..000000000 --- a/api/src/main/java/com/getcode/model/chat/ChatMember.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.getcode.model.chat - -import com.codeinc.gen.chat.v2.ChatService -import com.getcode.model.ID -import com.getcode.utils.serializer.UUIDSerializer -import kotlinx.serialization.Contextual -import kotlinx.serialization.Serializable -import java.util.UUID - -/** - * A user in a chat - * - * @param id Globally unique ID for this chat member - * @param isSelf Is this chat member yourself? This enables client to identify which member_id - * is themselves. - * @param identity The chat member's identity if it has been revealed. - * @param pointers Chat message state for this member. This list will have DELIVERED and READ - * pointers, if they exist. SENT pointers should be inferred by persistence - * on server. - * @param numUnread Estimated number of unread messages for the chat member in this chat - * Only valid when `isSelf = true` - * @param isMuted Has the chat member muted this chat? - * Only valid when `isSelf = true` - * @param isSubscribed Is the chat member subscribed to this chat? - * Only valid when `isSelf = true` - */ -@Serializable -data class ChatMember( - @Serializable(with = UUIDSerializer::class) - val id: UUID, - val isSelf: Boolean, - val identity: Identity?, - val pointers: List, - val numUnread: Int, - val isMuted: Boolean, - val isSubscribed: Boolean, -) - -/** - * Identity to an external social platform that can be linked to a Code account - * - * @param platform The external social platform linked to this chat member - * @param username The chat member's username on the external social platform - */ -@Serializable -data class Identity( - val platform: Platform, - val username: String, - val imageUrl: String? -) { - companion object { - operator fun invoke(proto: ChatService.ChatMemberIdentity): Identity? { - val platform = Platform(proto.platform).takeIf { it != Platform.Unknown } ?: return null - return Identity( - platform = platform, - username = proto.username, - imageUrl = null, - ) - } - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/chat/ChatMessage.kt b/api/src/main/java/com/getcode/model/chat/ChatMessage.kt deleted file mode 100644 index cf4660aa4..000000000 --- a/api/src/main/java/com/getcode/model/chat/ChatMessage.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.getcode.model.chat - -import com.getcode.ed25519.Ed25519.KeyPair -import com.getcode.model.Cursor -import com.getcode.model.ID -import com.getcode.utils.serializer.UUIDSerializer -import kotlinx.serialization.Serializable -import java.util.UUID - -/** - * A message in a chat - * - * @param id Globally unique ID for this message - * This is a time based UUID in v2 - * @param senderId The chat member that sent the message. - * For [ChatType.Notification] chats, this field is omitted since the chat has exactly 1 member. - * @param cursor Cursor value for this message for reference in a paged GetMessagesRequest - * @param dateMillis Timestamp this message was generated at - * @param contents Ordered message content. A message may have more than one piece of content. - */ -@Serializable -data class ChatMessage( - val id: ID, // time based UUID in v2 - @Serializable(with = UUIDSerializer::class) - val senderId: UUID?, - val isFromSelf: Boolean, - val cursor: Cursor, - val dateMillis: Long, - val contents: List, -) { - val hasEncryptedContent: Boolean - get() { - return contents.firstOrNull { it is MessageContent.SodiumBox } != null - } - - fun decryptingUsing(keyPair: KeyPair): ChatMessage { - return ChatMessage( - id = id, - senderId = senderId, - isFromSelf = isFromSelf, - dateMillis = dateMillis, - cursor = cursor, - contents = contents.map { - when (it) { - is MessageContent.Exchange, - is MessageContent.Localized, - is MessageContent.Decrypted, - is MessageContent.IdentityRevealed, - is MessageContent.RawText, - is MessageContent.ThankYou -> it // passthrough - is MessageContent.SodiumBox -> { - val decrypted = it.data.decryptMessageUsingNaClBox(keyPair = keyPair) - if (decrypted != null) { - MessageContent.Decrypted(data = decrypted, isFromSelf = isFromSelf) - } else { - it - } - } - - - } - } - ) - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/chat/ChatStreamEventUpdate.kt b/api/src/main/java/com/getcode/model/chat/ChatStreamEventUpdate.kt deleted file mode 100644 index e0e1612c9..000000000 --- a/api/src/main/java/com/getcode/model/chat/ChatStreamEventUpdate.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.getcode.model.chat - -import com.getcode.mapper.PointerStatus - -data class ChatStreamEventUpdate( - val messages: List, - val pointers: List, - val isTyping: Boolean, -) \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/chat/ChatType.kt b/api/src/main/java/com/getcode/model/chat/ChatType.kt deleted file mode 100644 index 72ea2119d..000000000 --- a/api/src/main/java/com/getcode/model/chat/ChatType.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.getcode.model.chat - -import com.codeinc.gen.chat.v2.ChatService - -enum class ChatType { - Unknown, - Notification, - TwoWay; - - companion object { - operator fun invoke(proto: ChatService.ChatType): ChatType { - return runCatching { entries[proto.ordinal] }.getOrNull() ?: Unknown - } - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/chat/Chats.kt b/api/src/main/java/com/getcode/model/chat/Chats.kt deleted file mode 100644 index eab91b077..000000000 --- a/api/src/main/java/com/getcode/model/chat/Chats.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.getcode.model.chat - -typealias ModelIntentId = com.codeinc.gen.common.v1.Model.IntentId - -typealias ChatGrpcV1 = com.codeinc.gen.chat.v1.ChatGrpc -typealias ChatGrpcV2 = com.codeinc.gen.chat.v2.ChatGrpc - -typealias ChatIdV1 = com.codeinc.gen.chat.v1.ChatService.ChatId -typealias ChatIdV2 = com.codeinc.gen.common.v1.Model.ChatId - -typealias MessageContentV1 = com.codeinc.gen.chat.v1.ChatService.Content -typealias MessageContentV2 = com.codeinc.gen.chat.v2.ChatService.Content - -typealias VerbV1 = com.codeinc.gen.chat.v1.ChatService.ExchangeDataContent.Verb -typealias VerbV2 = com.codeinc.gen.chat.v2.ChatService.ExchangeDataContent.Verb - -typealias ChatCursorV1 = com.codeinc.gen.chat.v1.ChatService.Cursor -typealias ChatCursorV2 = com.codeinc.gen.chat.v2.ChatService.Cursor - -typealias GetMessagesDirectionV1 = com.codeinc.gen.chat.v1.ChatService.GetMessagesRequest.Direction -typealias GetMessagesDirectionV2 = com.codeinc.gen.chat.v2.ChatService.GetMessagesRequest.Direction - -typealias PointerV1 = com.codeinc.gen.chat.v1.ChatService.Pointer -typealias PointerV2 = com.codeinc.gen.chat.v2.ChatService.Pointer - -typealias StartChatRequest = com.codeinc.gen.chat.v2.ChatService.StartChatRequest -typealias StartChatResponse = com.codeinc.gen.chat.v2.ChatService.StartChatResponse - -typealias GetChatsRequestV1 = com.codeinc.gen.chat.v1.ChatService.GetChatsRequest -typealias GetChatsRequestV2 = com.codeinc.gen.chat.v2.ChatService.GetChatsRequest -typealias GetChatsResponseV1 = com.codeinc.gen.chat.v1.ChatService.GetChatsResponse -typealias GetChatsResponseV2 = com.codeinc.gen.chat.v2.ChatService.GetChatsResponse - -typealias GetMessagesRequestV1 = com.codeinc.gen.chat.v1.ChatService.GetMessagesRequest -typealias GetMessagesRequestV2 = com.codeinc.gen.chat.v2.ChatService.GetMessagesRequest -typealias GetMessagesResponseV1 = com.codeinc.gen.chat.v1.ChatService.GetMessagesResponse -typealias GetMessagesResponseV2 = com.codeinc.gen.chat.v2.ChatService.GetMessagesResponse - -typealias AdvancePointerRequestV1 = com.codeinc.gen.chat.v1.ChatService.AdvancePointerRequest -typealias AdvancePointerRequestV2 = com.codeinc.gen.chat.v2.ChatService.AdvancePointerRequest -typealias AdvancePointerResponseV1 = com.codeinc.gen.chat.v1.ChatService.AdvancePointerResponse -typealias AdvancePointerResponseV2 = com.codeinc.gen.chat.v2.ChatService.AdvancePointerResponse - - -typealias SetMuteStateRequestV1 = com.codeinc.gen.chat.v1.ChatService.SetMuteStateRequest -typealias SetMuteStateRequestV2 = com.codeinc.gen.chat.v2.ChatService.SetMuteStateRequest -typealias SetMuteStateResponseV1 = com.codeinc.gen.chat.v1.ChatService.SetMuteStateResponse -typealias SetMuteStateResponseV2 = com.codeinc.gen.chat.v2.ChatService.SetMuteStateResponse - - -typealias SetSubscriptionStateRequestV1 = com.codeinc.gen.chat.v1.ChatService.SetSubscriptionStateRequest -typealias SetSubscriptionStateRequestV2 = com.codeinc.gen.chat.v2.ChatService.SetSubscriptionStateRequest -typealias SetSubscriptionStateResponseV1 = com.codeinc.gen.chat.v1.ChatService.SetSubscriptionStateResponse -typealias SetSubscriptionStateResponseV2 = com.codeinc.gen.chat.v2.ChatService.SetSubscriptionStateResponse - -/** - * Code reference to a V1 [Chat] that serves as a collection of messages associated - * with a notification type (Tips, Cash Payments, Web Payments, etc.) - */ -typealias NotificationCollectionEntity = Chat - -/** - * Code reference to a V2 [Chat] that is a full end-to-end chat that suports - * peer-to-peer messaging between users. - */ -typealias ConversationEntity = Chat diff --git a/api/src/main/java/com/getcode/model/chat/MessageContent.kt b/api/src/main/java/com/getcode/model/chat/MessageContent.kt deleted file mode 100644 index d9dd7e054..000000000 --- a/api/src/main/java/com/getcode/model/chat/MessageContent.kt +++ /dev/null @@ -1,413 +0,0 @@ -package com.getcode.model.chat - -import com.codeinc.gen.chat.v1.ChatService as ChatServiceV1 -import com.codeinc.gen.chat.v2.ChatService -import com.getcode.model.CurrencyCode -import com.getcode.model.EncryptedData -import com.getcode.model.Fiat -import com.getcode.model.GenericAmount -import com.getcode.model.ID -import com.getcode.model.Kin -import com.getcode.model.KinAmount -import com.getcode.model.Rate -import com.getcode.network.repository.toPublicKey -import com.getcode.utils.serializer.MessageContentSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -@Serializable -sealed interface MessageContent { - val kind: Int - val isFromSelf: Boolean - - @Serializable - data class Localized( - val value: String, - override val isFromSelf: Boolean, - ) : MessageContent { - override val kind: Int = 0 - - override fun hashCode(): Int { - var result = value.hashCode() - result += isFromSelf.hashCode() - result += kind.hashCode() - - return result - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Localized - - if (value != other.value) return false - if (isFromSelf != other.isFromSelf) return false - if (kind != other.kind) return false - - return true - } - } - - @Serializable - data class RawText( - val value: String, - override val isFromSelf: Boolean, - ) : MessageContent { - override val kind: Int = 1 - - override fun hashCode(): Int { - var result = value.hashCode() - result += isFromSelf.hashCode() - result += kind.hashCode() - - return result - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as RawText - - if (value != other.value) return false - if (isFromSelf != other.isFromSelf) return false - if (kind != other.kind) return false - - return true - } - } - - @Serializable - data class Exchange( - val amount: GenericAmount, - val verb: Verb, - val reference: Reference, - val hasInteracted: Boolean, - override val isFromSelf: Boolean, - ) : MessageContent { - override val kind: Int = 2 - - override fun hashCode(): Int { - var result = amount.hashCode() - result += verb.hashCode() - result += reference.hashCode() - result += hasInteracted.hashCode() - result += isFromSelf.hashCode() - result += kind.hashCode() - - return result - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Exchange - - if (amount != other.amount) return false - if (verb != other.verb) return false - if (reference != other.reference) return false - if (hasInteracted != other.hasInteracted) return false - if (isFromSelf != other.isFromSelf) return false - if (kind != other.kind) return false - - return true - } - } - - @Serializable - data class SodiumBox( - val data: EncryptedData, - override val isFromSelf: Boolean, - ) : MessageContent { - override val kind: Int = 3 - - override fun hashCode(): Int { - var result = data.hashCode() - result += isFromSelf.hashCode() - result += kind.hashCode() - - return result - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as SodiumBox - - if (data != other.data) return false - if (isFromSelf != other.isFromSelf) return false - if (kind != other.kind) return false - - return true - } - } - - @Serializable - data class ThankYou( - val tipIntentId: ID, - override val isFromSelf: Boolean, - ) : MessageContent { - override val kind: Int = 4 - - override fun hashCode(): Int { - var result = tipIntentId.hashCode() - result += isFromSelf.hashCode() - result += kind.hashCode() - - return result - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as ThankYou - - if (tipIntentId != other.tipIntentId) return false - if (isFromSelf != other.isFromSelf) return false - if (kind != other.kind) return false - - return true - } - } - - @Serializable - data class IdentityRevealed( - val memberId: ID, - val identity: Identity, - override val isFromSelf: Boolean, - ) : MessageContent { - override val kind: Int = 5 - - override fun hashCode(): Int { - var result = memberId.hashCode() - result += identity.hashCode() - result += isFromSelf.hashCode() - result += kind.hashCode() - - return result - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as IdentityRevealed - - if (memberId != other.memberId) return false - if (identity != other.identity) return false - if (isFromSelf != other.isFromSelf) return false - if (kind != other.kind) return false - - return true - } - } - - @Serializable - data class Decrypted( - val data: String, - override val isFromSelf: Boolean, - ) : MessageContent { - override val kind: Int = 6 - - override fun hashCode(): Int { - var result = data.hashCode() - result += isFromSelf.hashCode() - result += kind.hashCode() - - return result - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Decrypted - - if (data != other.data) return false - if (isFromSelf != other.isFromSelf) return false - if (kind != other.kind) return false - - return true - } - } - - companion object { - operator fun invoke( - proto: MessageContentV2, - isFromSelf: Boolean = false, - ): MessageContent? { - return when (proto.typeCase) { - ChatService.Content.TypeCase.LOCALIZED -> Localized( - isFromSelf = isFromSelf, - value = proto.localized.keyOrText - ) - - ChatService.Content.TypeCase.EXCHANGE_DATA -> { - val verb = Verb(proto.exchangeData.verb) - when (proto.exchangeData.exchangeDataCase) { - ChatService.ExchangeDataContent.ExchangeDataCase.EXACT -> { - val exact = proto.exchangeData.exact - val currency = CurrencyCode.tryValueOf(exact.currency) ?: return null - val kinAmount = KinAmount.newInstance( - kin = Kin.fromQuarks(exact.quarks), - rate = Rate( - fx = exact.exchangeRate, - currency = currency - ) - ) - - - val reference = Reference(proto.exchangeData) - Exchange( - isFromSelf = isFromSelf, - amount = GenericAmount.Exact(kinAmount), - verb = verb, - reference = reference, - hasInteracted = false, - ) - } - - ChatService.ExchangeDataContent.ExchangeDataCase.PARTIAL -> { - val partial = proto.exchangeData.partial - val currency = CurrencyCode.tryValueOf(partial.currency) ?: return null - - val fiat = Fiat( - currency = currency, - amount = partial.nativeAmount - ) - - val reference = Reference(proto.exchangeData) - - Exchange( - isFromSelf = isFromSelf, - amount = GenericAmount.Partial(fiat), - verb = verb, - reference = reference, - hasInteracted = false - ) - } - - ChatService.ExchangeDataContent.ExchangeDataCase.EXCHANGEDATA_NOT_SET -> return null - else -> return null - } - } - - ChatService.Content.TypeCase.NACL_BOX -> { - val encryptedContent = proto.naclBox - val peerPublicKey = - encryptedContent.peerPublicKey.value.toByteArray().toPublicKey() - - val data = EncryptedData( - peerPublicKey = peerPublicKey, - nonce = encryptedContent.nonce.toByteArray().toList(), - encryptedData = encryptedContent.encryptedPayload.toByteArray().toList(), - ) - SodiumBox(isFromSelf = isFromSelf, data = data) - } - - ChatService.Content.TypeCase.THANK_YOU -> { - ThankYou( - isFromSelf = isFromSelf, - tipIntentId = proto.thankYou.tipIntent.value.toByteArray().toList() - ) - } - - ChatService.Content.TypeCase.IDENTITY_REVEALED -> { - IdentityRevealed( - isFromSelf = isFromSelf, - memberId = proto.identityRevealed.memberId.value.toByteArray().toList(), - identity = Identity(proto.identityRevealed.identity) ?: return null - ) - } - - ChatService.Content.TypeCase.TEXT -> RawText( - isFromSelf = isFromSelf, - value = proto.text.text - ) - - ChatService.Content.TypeCase.TYPE_NOT_SET -> return null - else -> return null - } - } - - fun fromV1( - messageId: ID, - proto: MessageContentV1, - ): MessageContent? { - return when (proto.typeCase) { - ChatServiceV1.Content.TypeCase.SERVER_LOCALIZED -> Localized( - isFromSelf = false, - value = proto.serverLocalized.keyOrText - ) - - ChatServiceV1.Content.TypeCase.EXCHANGE_DATA -> { - val verb = Verb.fromV1(proto.exchangeData.verb) - val isFromSelf = !verb.increasesBalance - when (proto.exchangeData.exchangeDataCase) { - ChatServiceV1.ExchangeDataContent.ExchangeDataCase.EXACT -> { - val exact = proto.exchangeData.exact - val currency = CurrencyCode.tryValueOf(exact.currency) ?: return null - val kinAmount = KinAmount.newInstance( - kin = Kin.fromQuarks(exact.quarks), - rate = Rate( - fx = exact.exchangeRate, - currency = currency - ) - ) - - Exchange( - isFromSelf = isFromSelf, - amount = GenericAmount.Exact(kinAmount), - verb = verb, - reference = Reference.IntentId(messageId), - hasInteracted = false, - ) - } - - ChatServiceV1.ExchangeDataContent.ExchangeDataCase.PARTIAL -> { - val partial = proto.exchangeData.partial - val currency = CurrencyCode.tryValueOf(partial.currency) ?: return null - - val fiat = Fiat( - currency = currency, - amount = partial.nativeAmount - ) - - Exchange( - isFromSelf = isFromSelf, - amount = GenericAmount.Partial(fiat), - verb = verb, - reference = Reference.IntentId(messageId), - hasInteracted = false - ) - } - - ChatServiceV1.ExchangeDataContent.ExchangeDataCase.EXCHANGEDATA_NOT_SET -> return null - else -> return null - } - } - - ChatServiceV1.Content.TypeCase.NACL_BOX -> { - val encryptedContent = proto.naclBox - val peerPublicKey = - encryptedContent.peerPublicKey.value.toByteArray().toPublicKey() - - val data = EncryptedData( - peerPublicKey = peerPublicKey, - nonce = encryptedContent.nonce.toByteArray().toList(), - encryptedData = encryptedContent.encryptedPayload.toByteArray().toList(), - ) - SodiumBox(isFromSelf = false, data = data) - } - - ChatServiceV1.Content.TypeCase.TYPE_NOT_SET -> return null - else -> return null - } - } - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/chat/OutgoingMessageContent.kt b/api/src/main/java/com/getcode/model/chat/OutgoingMessageContent.kt deleted file mode 100644 index fc6552794..000000000 --- a/api/src/main/java/com/getcode/model/chat/OutgoingMessageContent.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.getcode.model.chat - -import com.getcode.model.ID - -sealed interface OutgoingMessageContent { - data class Text(val text: String): OutgoingMessageContent - data class ThankYou(val tipIntentId: ID): OutgoingMessageContent -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/chat/Reference.kt b/api/src/main/java/com/getcode/model/chat/Reference.kt deleted file mode 100644 index b36d563e4..000000000 --- a/api/src/main/java/com/getcode/model/chat/Reference.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.getcode.model.chat - -import com.codeinc.gen.chat.v2.ChatService.ExchangeDataContent -import com.codeinc.gen.chat.v2.ChatService.ExchangeDataContent.ReferenceCase -import com.getcode.model.ID -import com.getcode.solana.keys.Signature - -/** - * An ID that can be referenced to the source of the exchange of Kin - */ -sealed interface Reference { - data object NoneSet: Reference - data class IntentId(val id: ID): Reference - data class Signature(val signature: com.getcode.solana.keys.Signature): Reference - - companion object { - operator fun invoke(proto: ExchangeDataContent): Reference { - return when (proto.referenceCase) { - ReferenceCase.INTENT -> IntentId(proto.intent.toByteArray().toList()) - ReferenceCase.SIGNATURE -> Signature(Signature(proto.signature.toByteArray().toList())) - ReferenceCase.REFERENCE_NOT_SET -> NoneSet - null -> NoneSet - } - } - } -} diff --git a/api/src/main/java/com/getcode/model/chat/Verb.kt b/api/src/main/java/com/getcode/model/chat/Verb.kt deleted file mode 100644 index 383106dd1..000000000 --- a/api/src/main/java/com/getcode/model/chat/Verb.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.getcode.model.chat - -import com.codeinc.gen.chat.v1.ChatService as ChatServiceV1 -import com.codeinc.gen.chat.v2.ChatService - -sealed interface Verb { - val increasesBalance: Boolean - - data object Unknown : Verb { - override val increasesBalance: Boolean = false - } - - data object Gave : Verb { - override val increasesBalance: Boolean = false - } - - data object Received : Verb { - override val increasesBalance: Boolean = true - } - - data object Withdrew : Verb { - override val increasesBalance: Boolean = false - } - - data object Deposited : Verb { - override val increasesBalance: Boolean = true - } - - data object Sent : Verb { - override val increasesBalance: Boolean = false - } - - data object Returned : Verb { - override val increasesBalance: Boolean = true - } - - data object Spent : Verb { - override val increasesBalance: Boolean = false - } - - data object Paid : Verb { - override val increasesBalance: Boolean = false - } - - data object Purchased : Verb { - override val increasesBalance: Boolean = true - } - - data object ReceivedTip : Verb { - override val increasesBalance: Boolean = true - } - - data object SentTip : Verb { - override val increasesBalance: Boolean = false - } - - companion object { - operator fun invoke(proto: VerbV2): Verb { - return when (proto) { - ChatService.ExchangeDataContent.Verb.UNKNOWN -> Unknown - ChatService.ExchangeDataContent.Verb.GAVE -> Gave - ChatService.ExchangeDataContent.Verb.RECEIVED -> Received - ChatService.ExchangeDataContent.Verb.WITHDREW -> Withdrew - ChatService.ExchangeDataContent.Verb.DEPOSITED -> Deposited - ChatService.ExchangeDataContent.Verb.SENT -> Sent - ChatService.ExchangeDataContent.Verb.RETURNED -> Returned - ChatService.ExchangeDataContent.Verb.SPENT -> Spent - ChatService.ExchangeDataContent.Verb.PAID -> Paid - ChatService.ExchangeDataContent.Verb.PURCHASED -> Purchased - ChatService.ExchangeDataContent.Verb.UNRECOGNIZED -> Unknown - ChatService.ExchangeDataContent.Verb.RECEIVED_TIP -> ReceivedTip - ChatService.ExchangeDataContent.Verb.SENT_TIP -> SentTip - } - } - - fun fromV1(proto: VerbV1): Verb { - return when (proto) { - ChatServiceV1.ExchangeDataContent.Verb.UNKNOWN -> Unknown - ChatServiceV1.ExchangeDataContent.Verb.GAVE -> Gave - ChatServiceV1.ExchangeDataContent.Verb.RECEIVED -> Received - ChatServiceV1.ExchangeDataContent.Verb.WITHDREW -> Withdrew - ChatServiceV1.ExchangeDataContent.Verb.DEPOSITED -> Deposited - ChatServiceV1.ExchangeDataContent.Verb.SENT -> Sent - ChatServiceV1.ExchangeDataContent.Verb.RETURNED -> Returned - ChatServiceV1.ExchangeDataContent.Verb.SPENT -> Spent - ChatServiceV1.ExchangeDataContent.Verb.PAID -> Paid - ChatServiceV1.ExchangeDataContent.Verb.PURCHASED -> Purchased - ChatServiceV1.ExchangeDataContent.Verb.UNRECOGNIZED -> Unknown - ChatServiceV1.ExchangeDataContent.Verb.RECEIVED_TIP -> ReceivedTip - ChatServiceV1.ExchangeDataContent.Verb.SENT_TIP -> SentTip - } - } - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/ChatHistoryController.kt b/api/src/main/java/com/getcode/network/ChatHistoryController.kt deleted file mode 100644 index 6cd51243e..000000000 --- a/api/src/main/java/com/getcode/network/ChatHistoryController.kt +++ /dev/null @@ -1,221 +0,0 @@ -package com.getcode.network - -import androidx.paging.PagingData -import androidx.paging.PagingSource -import com.getcode.db.AppDatabase -import com.getcode.db.Database -import com.getcode.ed25519.Ed25519.KeyPair -import com.getcode.manager.SessionManager -import com.getcode.mapper.ConversationMapper -import com.getcode.mapper.ConversationMessageMapper -import com.getcode.model.Cursor -import com.getcode.model.ID -import com.getcode.model.MessageStatus -import com.getcode.model.chat.ChatMember -import com.getcode.model.chat.ChatMessage -import com.getcode.model.chat.ConversationEntity -import com.getcode.model.chat.Identity -import com.getcode.model.chat.Platform -import com.getcode.model.chat.Title -import com.getcode.model.chat.isConversation -import com.getcode.model.chat.selfId -import com.getcode.network.client.Client -import com.getcode.network.client.advancePointer -import com.getcode.network.client.fetchMessagesFor -import com.getcode.network.client.fetchV2Chats -import com.getcode.network.repository.encodeBase64 -import com.getcode.utils.TraceType -import com.getcode.utils.trace -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.filterNotNull -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class ChatHistoryController @Inject constructor( - private val client: Client, - private val twitterUserController: TwitterUserController, - private val conversationMapper: ConversationMapper, - private val conversationMessageMapper: ConversationMessageMapper, -) : CoroutineScope by CoroutineScope(Dispatchers.IO) { - - private val chatEntries = MutableStateFlow?>(null) - - val chats: StateFlow?> - get() = chatEntries - .map { it?.filter { entry -> entry.isConversation } } - .stateIn(this, SharingStarted.Eagerly, emptyList()) - - var loadingMessages: Boolean = false - - private val db: AppDatabase by lazy { Database.requireInstance() } - - private val pagerMap = mutableMapOf>() - private val chatFlows = mutableMapOf>>() - - fun reset() { - pagerMap.clear() - chatFlows.clear() - } - - fun updateChatWithMessages(chat: ConversationEntity, messages: List) { - val updatedMessages = (chat.messages + messages).distinctBy { it.id } - val updatedChat = chat.copy(messages = updatedMessages) - val chats = chatEntries.value?.map { - if (it.id == updatedChat.id) { - updatedChat - } else { - it - } - }?.sortedByDescending { it.lastMessageMillis } - chatEntries.update { chats } - } - - val unreadCount = chats - .filterNotNull() - // Ignore muted chats and unsubscribed chats - .map { it.filter { c -> !c.isMuted && c.isSubscribed } } - .map { it.sumOf { c -> c.unreadCount } } - - private fun owner(): KeyPair? = SessionManager.getKeyPair() - - suspend fun fetch(update: Boolean = false) { - if (loadingMessages) return - - val updatedWithMessages = mutableListOf() - val containers = fetchChatsWithoutMessages() - trace(message = "Fetched ${containers.count()} chats", type = TraceType.Silent) - - if (!update) { - pagerMap.clear() - chatFlows.clear() - chatEntries.value = containers - - loadingMessages = true - } - - containers.onEach { chat -> - val members = fetchMemberImages(chat) - val updatedChat = chat.copy(members = members) - val result = fetchLatestMessageForChat(updatedChat) - result.onSuccess { message -> - if (message != null) { - updatedWithMessages.add(updatedChat.copy(messages = listOf(message))) - } - }.onFailure { - updatedWithMessages.add(updatedChat) - } - } - - loadingMessages = false - chatEntries.value = updatedWithMessages.sortedByDescending { it.lastMessageMillis } - } - - fun addChat(chat: ConversationEntity) { - chatEntries.value = (chatEntries.value.orEmpty() + chat) - .sortedByDescending { it.lastMessageMillis } - } - - fun findChat(predicate: (ConversationEntity) -> Boolean): ConversationEntity? { - return chatEntries.value?.firstOrNull(predicate) - } - - fun resetUnreadCount(chatId: ID) { - chatEntries.update { - it?.toMutableList()?.apply chats@{ - indexOfFirst { chat -> chat.id == chatId } - .takeIf { index -> index >= 0 } - ?.let { index -> - val chat = this[index] - this[index] = chat.resetUnreadCount() - } - }?.toList() - } - } - - private suspend fun fetchLatestMessageForChat(chat: ConversationEntity): Result { - val encodedId = chat.id.toByteArray().encodeBase64() - Timber.d("fetching last message for $encodedId") - val owner = owner() ?: return Result.success(null) - return client.fetchMessagesFor(owner, chat, limit = 1) - .onSuccess { result -> - if (chat.isConversation) { - val messages = - result.map { message -> conversationMessageMapper.map(chat.id to message) } - val memberId = chat.selfId ?: return@onSuccess - val latestRef = messages.maxBy { it.dateMillis } - client.advancePointer( - owner, - chat, - latestRef.id, - memberId, - MessageStatus.Delivered - ) - } - } - .onFailure { - Timber.e(t = it, "Failed to fetch messages for $encodedId.") - }.map { it.getOrNull(0) } - } - - private suspend fun fetchChatsWithoutMessages(): List { - val owner = owner() ?: return emptyList() - val result = client.fetchV2Chats(owner) - .map { chats -> - chats.map { chat -> - // map revealed identity as title if known - if (chat.isConversation) { - val conversation = conversationMapper.map(chat) - conversation.name?.let { chat.copy(title = Title.Localized(it)) } ?: chat - } else { - chat - } - } - } - .onSuccess { result -> - result.filter { it.isConversation } - .let { chats -> - val chatIds = chats.map { it.id } - db.conversationDao().purgeConversationsNotIn(chatIds) - db.conversationMessageDao().purgeMessagesNotIn(chatIds) - db.conversationPointersDao().purgePointersNoLongerNeeded(chatIds) - db.conversationIntentMappingDao().purgeMappingNoLongerNeeded(chatIds) - chats - } - .onEach { - val conversation = conversationMapper.map(it) - db.conversationDao().upsertConversations(conversation) - } - } - return result.getOrNull().orEmpty() - } - - private suspend fun fetchMemberImages(chat: ConversationEntity): List { - return chat.members - .map { member -> - if (member.isSelf) return@map member - if (member.identity == null) return@map member - if (member.identity.imageUrl != null) return@map member - val metadata = runCatching { - twitterUserController.fetchUser(member.identity.username) - }.getOrNull() ?: return@map member - - member.copy( - identity = Identity( - platform = Platform.named(metadata.platform), - username = metadata.username, - imageUrl = metadata.imageUrl - ) - ) - } - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/ConversationController.kt b/api/src/main/java/com/getcode/network/ConversationController.kt deleted file mode 100644 index 01a685f7f..000000000 --- a/api/src/main/java/com/getcode/network/ConversationController.kt +++ /dev/null @@ -1,370 +0,0 @@ -package com.getcode.network - -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.PagingData -import com.getcode.api.BuildConfig -import com.getcode.db.AppDatabase -import com.getcode.db.Database -import com.getcode.manager.SessionManager -import com.getcode.mapper.ConversationMapper -import com.getcode.mapper.ConversationMessageWithContentMapper -import com.getcode.mapper.PointerStatus -import com.getcode.model.Conversation -import com.getcode.model.ConversationIntentIdReference -import com.getcode.model.ConversationMessageWithContent -import com.getcode.model.ConversationWithLastPointers -import com.getcode.model.ID -import com.getcode.model.MessageStatus -import com.getcode.model.SocialUser -import com.getcode.model.chat.ChatType -import com.getcode.model.chat.MessageContent -import com.getcode.model.chat.OutgoingMessageContent -import com.getcode.model.chat.Platform -import com.getcode.model.chat.Pointer -import com.getcode.model.chat.isConversation -import com.getcode.model.chat.selfId -import com.getcode.model.description -import com.getcode.model.uuid -import com.getcode.network.client.ChatMessageStreamReference -import com.getcode.network.exchange.Exchange -import com.getcode.network.repository.base58 -import com.getcode.network.service.ChatServiceV2 -import com.getcode.utils.ErrorUtils -import com.getcode.utils.TraceType -import com.getcode.utils.bytes -import com.getcode.utils.trace -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import javax.inject.Inject - -interface ConversationController { - fun observeConversation(id: ID): Flow - suspend fun createConversation(identifier: ID, with: SocialUser): Conversation - suspend fun getConversation(identifier: ID): ConversationWithLastPointers? - suspend fun getOrCreateConversation( - identifier: ID, - with: SocialUser - ): ConversationWithLastPointers - - fun openChatStream(scope: CoroutineScope, conversation: Conversation) - fun closeChatStream() - suspend fun hasInteracted(messageId: ID): Boolean - suspend fun revealIdentity( - conversationId: ID, - platform: Platform, - username: String - ): Result - - suspend fun resetUnreadCount(conversationId: ID) - suspend fun advanceReadPointer(conversationId: ID, messageId: ID, status: MessageStatus) - suspend fun sendMessage(conversationId: ID, message: String): Result - fun conversationPagingData(conversationId: ID): Flow> - fun observeTyping(conversationId: ID): Flow - suspend fun onUserStartedTypingIn(conversationId: ID) - suspend fun onUserStoppedTypingIn(conversationId: ID) -} - -class ConversationStreamController @Inject constructor( - private val historyController: ChatHistoryController, - private val exchange: Exchange, - private val chatService: ChatServiceV2, - private val conversationMapper: ConversationMapper, - private val messageWithContentMapper: ConversationMessageWithContentMapper, - private val tipController: TipController, -) : ConversationController { - private val pagingConfig = PagingConfig(pageSize = 20) - - private val db: AppDatabase by lazy { Database.requireInstance() } - - private var stream: ChatMessageStreamReference? = null - - private val typingChats = MutableStateFlow>(emptyList()) - - private fun conversationPagingSource(conversationId: ID) = - db.conversationMessageDao().observeConversationMessages(conversationId.base58) - - override fun observeConversation(id: ID): Flow { - return db.conversationDao().observeConversation(id) - } - - override suspend fun createConversation(identifier: ID, with: SocialUser): Conversation { - val owner = SessionManager.getOrganizer()?.ownerKeyPair ?: throw IllegalStateException() - val self = tipController.connectedAccount.value ?: throw IllegalStateException() - return chatService.startChat(owner, self, with, identifier, ChatType.TwoWay) - .onSuccess { historyController.addChat(it) } - .map { conversationMapper.map(it) } - .onSuccess { - // TODO: remove - // stop gap until startChat rejects created chats for the same identifier - db.conversationIntentMappingDao() - .insert( - ConversationIntentIdReference( - conversationIdBase58 = it.id.base58, - intentIdBase58 = identifier.base58 - ) - ) - db.conversationDao().upsertConversations(it) - } - .getOrThrow() - } - - override suspend fun getConversation(identifier: ID): ConversationWithLastPointers? { - val conversation = db.conversationDao().findConversation(identifier) ?: return null - return conversation - } - - override suspend fun getOrCreateConversation( - identifier: ID, - with: SocialUser - ): ConversationWithLastPointers { - var conversationByChatId = getConversation(identifier) - if (conversationByChatId != null) { - return conversationByChatId - } - - // lookup chat ID by tip intent ID - val conversationId = db.conversationIntentMappingDao().conversationIdByReference(identifier) - if (conversationId != null) { - conversationByChatId = getConversation(identifier) - } - - if (conversationByChatId != null) { - return conversationByChatId - } - - return ConversationWithLastPointers(createConversation(identifier, with), emptyList()) - } - - @Throws(IllegalStateException::class) - override fun openChatStream(scope: CoroutineScope, conversation: Conversation) { - runCatching { closeChatStream() } - val owner = SessionManager.getOrganizer()?.ownerKeyPair ?: throw IllegalStateException() - - val chat = historyController.findChat { it.id == conversation.id } - ?: throw IllegalArgumentException("Unable to resolve chat for this conversation") - val memberId = chat.selfId ?: throw IllegalStateException("Not a member of this chat") - - scope.launch(Dispatchers.IO) { - savePointers(conversationId = conversation.id, - pointers = chat.members.flatMap { member -> - member.pointers.mapNotNull { ptr -> - val messageId = ptr.messageId ?: return@mapNotNull null - val uuid = ptr.memberId?.uuid ?: return@mapNotNull null - PointerStatus( - memberId = uuid, - messageId = messageId, - messageStatus = when (ptr) { - is Pointer.Delivered -> MessageStatus.Delivered - is Pointer.Read -> MessageStatus.Read - is Pointer.Sent -> MessageStatus.Sent - is Pointer.Unknown -> MessageStatus.Unknown - } - ) - } - } - ) - } - - stream = chatService.openChatStream( - scope = scope, - conversation = conversation, - memberId = memberId, - owner = owner, - chatLookup = { chat } - ) result@{ result -> - if (result.isSuccess) { - val updates = result.getOrNull() ?: return@result - val (messages, pointers, isTyping) = updates - - typingChats.value = if (isTyping) { - typingChats.value + listOf(conversation.id).toSet() - } else { - typingChats.value - listOf(conversation.id).toSet() - } - - historyController.updateChatWithMessages(chat, messages) - val messagesWithContent = messages.map { - messageWithContentMapper.map(chat.id to it) - } - - val identityRevealed = messages - .flatMap { it.contents } - .filterIsInstance() - .firstOrNull() - .takeIf { chat.isConversation } - - if (identityRevealed != null && conversation.members.isNotEmpty()) { - val members = conversation.members.map { - if (identityRevealed.memberId == it.id.bytes) { - it.copy(identity = identityRevealed.identity) - } else { - it - } - } - scope.launch(Dispatchers.IO) { - db.conversationDao() - .upsertConversations( - conversation.copy(members = members) - ) - } - } - - trace( - tag = "ConversationStream", - message = "chat messages: ${messages.count()}, pointers=${pointers.count()}, isTyping=$isTyping", - type = TraceType.Silent - ) - - scope.launch(Dispatchers.IO) { - db.conversationMessageDao().upsertMessagesWithContent(messagesWithContent) - savePointers(conversationId = conversation.id, pointers = pointers) - } - } else { - result.exceptionOrNull()?.let { - ErrorUtils.handleError(it) - } - } - } - } - - private suspend fun savePointers( - conversationId: ID, - pointers: List - ) { - pointers - .onEach { - db.conversationPointersDao().insert( - conversationId = conversationId, - messageId = it.messageId, - status = it.messageStatus - ) - } - } - - override fun closeChatStream() { - stream?.destroy() - } - - override suspend fun hasInteracted(messageId: ID): Boolean { - // TODO: will require conversation model update - return false - } - - override suspend fun revealIdentity( - conversationId: ID, - platform: Platform, - username: String - ): Result { - val owner = SessionManager.getOrganizer()?.ownerKeyPair - ?: return Result.failure(Throwable("owner not found")) - val chat = historyController.findChat { it.id == conversationId } - ?: return Result.failure(Throwable("Chat not found")) - - val memberId = chat.selfId ?: return Result.failure(Throwable("Not member of chat")) - - return chatService.revealIdentity(owner, chat, memberId, platform, username) - .map { } - .onSuccess { - db.conversationDao().revealIdentity(conversationId) - } - } - - override suspend fun resetUnreadCount(conversationId: ID) { - val chat = historyController.findChat { it.id == conversationId } ?: return - historyController.resetUnreadCount(chat.id) - } - - override suspend fun advanceReadPointer( - conversationId: ID, - messageId: ID, - status: MessageStatus - ) { - val owner = SessionManager.getOrganizer()?.ownerKeyPair ?: return - - val chat = historyController.findChat { it.id == conversationId } ?: return - - val memberId = chat.selfId ?: return - - chatService.advancePointer(owner, conversationId, memberId, messageId, status) - .onSuccess { - trace("advanced pointer for chat on ${messageId.description} => $status") - }.onFailure { it.printStackTrace() } - } - - override suspend fun sendMessage(conversationId: ID, message: String): Result { - val owner = SessionManager.getOrganizer()?.ownerKeyPair - ?: return Result.failure(Throwable("Owner not found")) - - val chat = historyController.findChat { it.id == conversationId } - ?: return Result.failure(Throwable("Chat not found")) - - val memberId = chat.selfId ?: return Result.failure(Throwable("Not a member of this chat")) - - return chatService.sendMessage( - owner = owner, - chat = chat, - memberId = memberId, - content = OutgoingMessageContent.Text(message) - ).map { - val messageWithContent = messageWithContentMapper.map(conversationId to it) - CoroutineScope(Dispatchers.IO).launch { - db.conversationMessageDao().upsertMessagesWithContent(messageWithContent) - } - - messageWithContent.message.id - } - } - - override fun conversationPagingData(conversationId: ID) = - Pager( - config = pagingConfig, - initialKey = null, - ) { conversationPagingSource(conversationId) }.flow - - override fun observeTyping(conversationId: ID): Flow = - typingChats.map { it.contains(conversationId) } - - override suspend fun onUserStartedTypingIn(conversationId: ID) { - val owner = SessionManager.getOrganizer()?.ownerKeyPair ?: return - - val chat = historyController.findChat { it.id == conversationId } - ?: return - - val memberId = chat.selfId ?: return - - chatService.onStartedTyping( - owner, chat, memberId - ).onSuccess { - println("on typing started reported") - }.onFailure { - if (BuildConfig.DEBUG) { - it.printStackTrace() - } - } - } - - override suspend fun onUserStoppedTypingIn(conversationId: ID) { - val owner = SessionManager.getOrganizer()?.ownerKeyPair ?: return - - val chat = historyController.findChat { it.id == conversationId } - ?: return - - val memberId = chat.selfId ?: return - - chatService.onStoppedTyping( - owner, chat, memberId - ).onSuccess { - println("on typing stopped reported") - }.onFailure { - if (BuildConfig.DEBUG) { - it.printStackTrace() - } - } - } - -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/ConversationListController.kt b/api/src/main/java/com/getcode/network/ConversationListController.kt deleted file mode 100644 index 64ccf6bdd..000000000 --- a/api/src/main/java/com/getcode/network/ConversationListController.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.getcode.network - -import androidx.paging.PagingSource -import androidx.paging.PagingState -import com.getcode.model.chat.Chat -import javax.inject.Inject - -class ConversationListController @Inject constructor( - private val historyController: ChatHistoryController, -) { - val isLoading: Boolean - get() = historyController.loadingMessages - - fun observeConversations() = historyController.chats - - suspend fun fetchChats() = historyController.fetch(true) -} - -class ChatPagingSource( - private val chats: List -) : PagingSource() { - - override fun getRefreshKey(state: PagingState): Int? { - return state.anchorPosition?.let { anchorPosition -> - val anchorPage = state.closestPageToPosition(anchorPosition) - anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1) - } - } - - override suspend fun load(params: LoadParams): LoadResult { - val currentList = chats - val position = params.key ?: 0 - val pageSize = params.loadSize - - return try { - val items = currentList.subList( - position.coerceAtMost(currentList.size), - (position + pageSize).coerceAtMost(currentList.size) - ) - - LoadResult.Page( - data = items, - prevKey = if (position > 0) position - pageSize else null, - nextKey = if (position + pageSize < currentList.size) position + pageSize else null - ) - } catch (e: Exception) { - LoadResult.Error(e) - } - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/api/ChatApiV2.kt b/api/src/main/java/com/getcode/network/api/ChatApiV2.kt deleted file mode 100644 index 23045dacf..000000000 --- a/api/src/main/java/com/getcode/network/api/ChatApiV2.kt +++ /dev/null @@ -1,327 +0,0 @@ -package com.getcode.network.api - -import com.codeinc.gen.chat.v2.ChatService -import com.codeinc.gen.chat.v2.ChatService.ChatMemberIdentity -import com.codeinc.gen.chat.v2.ChatService.Content -import com.codeinc.gen.chat.v2.ChatService.NotifyIsTypingRequest -import com.codeinc.gen.chat.v2.ChatService.NotifyIsTypingResponse -import com.codeinc.gen.chat.v2.ChatService.PointerType -import com.codeinc.gen.chat.v2.ChatService.RevealIdentityRequest -import com.codeinc.gen.chat.v2.ChatService.RevealIdentityResponse -import com.codeinc.gen.chat.v2.ChatService.SendMessageRequest -import com.codeinc.gen.chat.v2.ChatService.SendMessageResponse -import com.codeinc.gen.common.v1.Model -import com.getcode.ed25519.Ed25519.KeyPair -import com.getcode.model.Cursor -import com.getcode.model.ID -import com.getcode.model.SocialUser -import com.getcode.model.chat.ChatMember -import com.getcode.model.chat.OutgoingMessageContent -import com.getcode.model.chat.Platform -import com.getcode.model.chat.StartChatRequest -import com.getcode.model.chat.StartChatResponse -import com.getcode.network.core.GrpcApi -import com.getcode.network.repository.toByteString -import com.getcode.network.repository.toSolanaAccount -import com.getcode.solana.keys.PublicKey -import com.getcode.utils.bytes -import com.getcode.utils.sign -import io.grpc.ManagedChannel -import io.grpc.stub.StreamObserver -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOn -import java.util.UUID -import javax.inject.Inject -import com.getcode.model.chat.AdvancePointerRequestV2 as AdvancePointerRequest -import com.getcode.model.chat.AdvancePointerResponseV2 as AdvancePointerResponse -import com.getcode.model.chat.ChatCursorV2 as ChatCursor -import com.getcode.model.chat.ChatGrpcV2 as ChatGrpc -import com.getcode.model.chat.ChatIdV2 as ChatId -import com.getcode.model.chat.GetChatsRequestV2 as GetChatsRequest -import com.getcode.model.chat.GetChatsResponseV2 as GetChatsResponse -import com.getcode.model.chat.GetMessagesDirectionV2 as GetMessagesDirection -import com.getcode.model.chat.GetMessagesRequestV2 as GetMessagesRequest -import com.getcode.model.chat.GetMessagesResponseV2 as GetMessagesResponse -import com.getcode.model.chat.ModelIntentId as IntentId -import com.getcode.model.chat.PointerV2 as Pointer -import com.getcode.model.chat.SetMuteStateRequestV2 as SetMuteStateRequest -import com.getcode.model.chat.SetMuteStateResponseV2 as SetMuteStateResponse -import com.getcode.model.chat.SetSubscriptionStateRequestV2 as SetSubscriptionStateRequest -import com.getcode.model.chat.SetSubscriptionStateResponseV2 as SetSubscriptionStateResponse - -class ChatApiV2 @Inject constructor( - managedChannel: ManagedChannel -) : GrpcApi(managedChannel) { - private val api = ChatGrpc.newStub(managedChannel).withWaitForReady() - - fun startChat( - owner: KeyPair, - self: SocialUser, - with: SocialUser, - intentId: ID - ): Flow { - val request = StartChatRequest.newBuilder() - .setOwner(owner.publicKeyBytes.toSolanaAccount()) - .setSelf(self.chatMemberIdentity) - .setTwoWayChat( - ChatService.StartTwoWayChatParameters.newBuilder() - .setIdentity(with.chatMemberIdentity) - .setIntentId(IntentId.newBuilder().setValue(intentId.toByteString())) - .setOtherUser(with.tipAddress.bytes.toSolanaAccount()) - .build() - ) - .apply { setSignature(sign(owner)) } - .build() - - return api::startChat - .callAsCancellableFlow(request) - .flowOn(Dispatchers.IO) - } - - fun fetchChats(owner: KeyPair): Flow { - val request = GetChatsRequest.newBuilder() - .setOwner(owner.publicKeyBytes.toSolanaAccount()) - .apply { setSignature(sign(owner)) } - .build() - - return api::getChats - .callAsCancellableFlow(request) - .flowOn(Dispatchers.IO) - } - - fun fetchChatMessages( - owner: KeyPair, - chatId: ID, - memberId: UUID, - cursor: Cursor? = null, - limit: Int? = null - ): Flow { - val builder = GetMessagesRequest.newBuilder() - .setChatId( - ChatId.newBuilder() - .setValue(chatId.toByteString()) - .build() - ).setMemberId(ChatService.ChatMemberId.newBuilder() - .setValue(memberId.bytes.toByteString()) - ) - - if (cursor != null) { - builder.setCursor( - ChatCursor.newBuilder() - .setValue(cursor.toByteString()) - ) - } - - if (limit != null) { - builder.setPageSize(limit) - } - - builder.setDirection(GetMessagesDirection.DESC) - - val request = builder - .setOwner(owner.publicKeyBytes.toSolanaAccount()) - .apply { setSignature(sign(owner)) } - .build() - - return api::getMessages - .callAsCancellableFlow(request) - .flowOn(Dispatchers.IO) - } - - fun advancePointer(owner: KeyPair, chatId: ID, memberId: UUID, to: ID, type: PointerType): Flow { - val request = AdvancePointerRequest.newBuilder() - .setChatId( - ChatId.newBuilder() - .setValue(chatId.toByteArray().toByteString()) - .build() - ).setPointer( - Pointer.newBuilder() - .setType(type) - .setMemberId(ChatService.ChatMemberId.newBuilder() - .setValue(memberId.bytes.toByteString())) - .setValue( - ChatService.ChatMessageId.newBuilder() - .setValue(to.toByteArray().toByteString()) - ) - ).setOwner(owner.publicKeyBytes.toSolanaAccount()) - .apply { setSignature(sign(owner)) } - .build() - - return api::advancePointer - .callAsCancellableFlow(request) - .flowOn(Dispatchers.IO) - } - - fun setMuteState(owner: KeyPair, chatId: ID, muted: Boolean): Flow { - val request = SetMuteStateRequest.newBuilder() - .setChatId( - ChatId.newBuilder() - .setValue(chatId.toByteArray().toByteString()) - .build() - ).setIsMuted(muted) - .setOwner(owner.publicKeyBytes.toSolanaAccount()) - .apply { setSignature(sign(owner)) } - .build() - - return api::setMuteState - .callAsCancellableFlow(request) - .flowOn(Dispatchers.IO) - } - - fun setSubscriptionState( - owner: KeyPair, - chatId: ID, - subscribed: Boolean - ): Flow { - val request = SetSubscriptionStateRequest.newBuilder() - .setChatId( - ChatId.newBuilder() - .setValue(chatId.toByteArray().toByteString()) - .build() - ).setIsSubscribed(subscribed) - .setOwner(owner.publicKeyBytes.toSolanaAccount()) - .apply { setSignature(sign(owner)) } - .build() - - return api::setSubscriptionState - .callAsCancellableFlow(request) - .flowOn(Dispatchers.IO) - } - - fun streamChatEvents( - observer: StreamObserver - ): StreamObserver? { - return api.streamChatEvents(observer) - } - - - /** - * ChatId chat_id = 1; - * - * ChatMemberId member_id = 2; - * - * // Allowed content types that can be sent by client: - * // - TextContent - * // - ThankYouContent - * repeated Content content = 3; - * - * common.v1.SolanaAccountId owner = 4; - * - * common.v1.Signature signature = 5; - */ - fun sendMessage( - owner: KeyPair, - chatId: ID, - memberId: UUID, - content: OutgoingMessageContent, - observer: StreamObserver - ) { - val contentProto = when (content) { - is OutgoingMessageContent.Text -> Content.newBuilder() - .setText(ChatService.TextContent.newBuilder().setText(content.text)) - is OutgoingMessageContent.ThankYou -> Content.newBuilder() - .setThankYou(ChatService.ThankYouContent.newBuilder() - .setTipIntent(Model.IntentId.newBuilder() - .setValue(content.tipIntentId.toByteString())) - ) - } - - val request = SendMessageRequest.newBuilder() - .setChatId(ChatId.newBuilder() - .setValue(chatId.toByteArray().toByteString()) - ) - .addContent(contentProto) - .setMemberId(ChatService.ChatMemberId.newBuilder() - .setValue(memberId.bytes.toByteString()) - ).setOwner(owner.publicKeyBytes.toSolanaAccount()) - .apply { setSignature(sign(owner)) } - .build() - - api.sendMessage(request, observer) - } - - fun revealIdentity( - owner: KeyPair, - chatId: ID, - memberId: UUID, - platform: Platform, - username: String, - observer: StreamObserver - ) { - val request = RevealIdentityRequest.newBuilder() - .setChatId(ChatId.newBuilder() - .setValue(chatId.toByteArray().toByteString()) - ) - .setMemberId(ChatService.ChatMemberId.newBuilder() - .setValue(memberId.bytes.toByteString()) - ) - .setIdentity(ChatMemberIdentity.newBuilder() - .setPlatformValue(platform.ordinal) - .setUsername(username) - ) - .setOwner(owner.publicKeyBytes.toSolanaAccount()) - .apply { setSignature(sign(owner)) } - .build() - - api.revealIdentity(request, observer) - } - - fun onStartedTyping( - owner: KeyPair, - chatId: ID, - memberId: UUID, - observer: StreamObserver - ) { - val request = NotifyIsTypingRequest.newBuilder() - .setChatId(ChatId.newBuilder() - .setValue(chatId.toByteArray().toByteString()) - ).setIsTyping(true) - .setMemberId(ChatService.ChatMemberId.newBuilder() - .setValue(memberId.bytes.toByteString()) - ) - .setOwner(owner.publicKeyBytes.toSolanaAccount()) - .apply { setSignature(sign(owner)) } - .build() - - api.notifyIsTyping(request, observer) - } - - fun onStoppedTyping( - owner: KeyPair, - chatId: ID, - memberId: UUID, - observer: StreamObserver - ) { - val request = NotifyIsTypingRequest.newBuilder() - .setChatId(ChatId.newBuilder() - .setValue(chatId.toByteArray().toByteString()) - ).setIsTyping(false) - .setMemberId(ChatService.ChatMemberId.newBuilder() - .setValue(memberId.bytes.toByteString()) - ) - .setOwner(owner.publicKeyBytes.toSolanaAccount()) - .apply { setSignature(sign(owner)) } - .build() - - api.notifyIsTyping(request, observer) - } -} - -private val SocialUser.chatMemberIdentity: ChatMemberIdentity - get() { - val builder = ChatMemberIdentity.newBuilder() - .setUsername(username) - .setPlatform( - when (Platform.named(platform)) { - Platform.Unknown -> ChatService.Platform.UNKNOWN_PLATFORM - Platform.Twitter -> ChatService.Platform.TWITTER - } - ) - - if (imageUrl != null) { - builder.setProfilePicUrl(imageUrl) - } - - return builder.build() - } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/client/Client_Chat.kt b/api/src/main/java/com/getcode/network/client/Client_Chat.kt deleted file mode 100644 index 004551522..000000000 --- a/api/src/main/java/com/getcode/network/client/Client_Chat.kt +++ /dev/null @@ -1,143 +0,0 @@ -package com.getcode.network.client - -import com.codeinc.gen.chat.v2.ChatService -import com.getcode.ed25519.Ed25519.KeyPair -import com.getcode.manager.SessionManager -import com.getcode.model.Cursor -import com.getcode.model.Domain -import com.getcode.model.ID -import com.getcode.model.MessageStatus -import com.getcode.model.chat.Chat -import com.getcode.model.chat.ChatMessage -import com.getcode.model.chat.ConversationEntity -import com.getcode.model.chat.NotificationCollectionEntity -import com.getcode.model.chat.isV2 -import com.getcode.network.core.BidirectionalStreamReference -import com.getcode.network.repository.base58 -import com.getcode.utils.TraceType -import com.getcode.utils.trace -import timber.log.Timber -import java.util.UUID - -typealias ChatMessageStreamReference = BidirectionalStreamReference - -data class ChatFetchExceptions(val errors: List): Throwable() - -suspend fun Client.fetchChats(owner: KeyPair): Result> { - val v2Chats = fetchV2Chats(owner) - val v1Chats = fetchV1Chats(owner) - - if (v2Chats.isSuccess || v1Chats.isSuccess) { - val chats = (v1Chats.getOrNull().orEmpty() + v2Chats.getOrNull().orEmpty()) - .sortedByDescending { it.lastMessageMillis } - .distinctBy { it.id } - - return Result.success(chats) - } else { - val errors: List = - listOfNotNull(v1Chats.exceptionOrNull(), v2Chats.exceptionOrNull()) - return Result.failure(ChatFetchExceptions(errors)) - } -} - -suspend fun Client.fetchV1Chats(owner: KeyPair): Result> { - val v1Chats = chatServiceV1.fetchChats(owner) - .onSuccess { - Timber.d("v1 chats fetched=${it.count()}") - }.onFailure { - trace("Failed fetching chats from V1", type = TraceType.Error) - } - - return v1Chats - .map { chats -> - chats.sortedByDescending { it.lastMessageMillis } - .distinctBy { it.id } - } -} - -suspend fun Client.fetchV2Chats(owner: KeyPair): Result> { - val v2Chats = chatServiceV2.fetchChats(owner) - .onSuccess { - Timber.d("v2 chats fetched=${it.count()}") - }.onFailure { - trace("Failed fetching chats from V2", type = TraceType.Error) - } - - return v2Chats - .map { chats -> - chats.sortedByDescending { it.lastMessageMillis } - .distinctBy { it.id } - } -} - - -suspend fun Client.setMuted(owner: KeyPair, chat: Chat, muted: Boolean): Result { - return if (chat.isV2) { - chatServiceV2.setMuteState(owner, chat.id, muted) - } else { - chatServiceV1.setMuteState(owner, chat.id, muted) - } -} - -suspend fun Client.setSubscriptionState( - owner: KeyPair, - chat: Chat, - subscribed: Boolean -): Result { - return if (chat.isV2) { - chatServiceV2.setSubscriptionState(owner, chat.id, subscribed) - } else { - chatServiceV1.setSubscriptionState(owner, chat.id, subscribed) - } -} - -suspend fun Client.fetchMessagesFor( - owner: KeyPair, - chat: Chat, - cursor: Cursor? = null, - limit: Int? = null -): Result> { - val result = if (chat.isV2) { - chatServiceV2.fetchMessagesFor(owner, chat, cursor, limit) - } else { - chatServiceV1.fetchMessagesFor(owner, chat, cursor, limit) - } - - return result - .mapCatching { messages -> - val organizer = SessionManager.getOrganizer() ?: return@mapCatching messages - val domain = Domain.from(chat.title?.value) ?: return@mapCatching messages - - val relationship = organizer.relationshipFor(domain) ?: return@mapCatching messages - - val hasEncryptedContent = messages.firstOrNull { it.hasEncryptedContent } != null - if (hasEncryptedContent) { - messages.map { message -> - message.decryptingUsing(relationship.getCluster().authority.keyPair) - } - } else { - messages - } - } - .onSuccess { - Timber.d("messages fetched=${it.count()} for ${chat.id.base58}") - Timber.d("start=${it.minOf { it.dateMillis }}, end=${it.maxOf { it.dateMillis }}") - }.onFailure { - Timber.e(t = it, "Failed fetching messages.") - } -} - -suspend fun Client.advancePointer( - owner: KeyPair, - chat: Chat, - to: ID, - memberId: UUID? = null, - status: MessageStatus = MessageStatus.Read, -): Result { - return if (chat.isV2) { - memberId ?: return Result.failure(Throwable("member ID was not provided")) - chatServiceV2.advancePointer(owner, chat.id, memberId, to, status) - } else { - chatServiceV1.advancePointer(owner, chat.id, to, status) - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/service/ChatServiceV2.kt b/api/src/main/java/com/getcode/network/service/ChatServiceV2.kt deleted file mode 100644 index 0c01599be..000000000 --- a/api/src/main/java/com/getcode/network/service/ChatServiceV2.kt +++ /dev/null @@ -1,799 +0,0 @@ -package com.getcode.network.service - -import com.codeinc.gen.chat.v2.ChatService -import com.codeinc.gen.chat.v2.ChatService.ChatMemberId -import com.codeinc.gen.chat.v2.ChatService.NotifyIsTypingResponse -import com.codeinc.gen.chat.v2.ChatService.OpenChatEventStream -import com.codeinc.gen.chat.v2.ChatService.PointerType -import com.codeinc.gen.chat.v2.ChatService.RevealIdentityResponse -import com.codeinc.gen.chat.v2.ChatService.SendMessageResponse -import com.codeinc.gen.chat.v2.ChatService.StreamChatEventsRequest -import com.codeinc.gen.chat.v2.ChatService.StreamChatEventsResponse -import com.codeinc.gen.common.v1.Model.ClientPong -import com.getcode.ed25519.Ed25519.KeyPair -import com.getcode.mapper.ChatMessageV2Mapper -import com.getcode.mapper.ChatMetadataV2Mapper -import com.getcode.mapper.PointerMapper -import com.getcode.model.Conversation -import com.getcode.model.Cursor -import com.getcode.model.chat.ChatMessage -import com.getcode.model.ID -import com.getcode.model.MessageStatus -import com.getcode.model.SocialUser -import com.getcode.model.chat.Chat -import com.getcode.model.chat.ChatIdV2 -import com.getcode.model.chat.ChatStreamEventUpdate -import com.getcode.model.chat.ChatType -import com.getcode.model.chat.OutgoingMessageContent -import com.getcode.model.chat.Platform -import com.getcode.model.description -import com.getcode.model.uuid -import com.getcode.network.api.ChatApiV2 -import com.getcode.network.client.ChatMessageStreamReference -import com.getcode.network.core.NetworkOracle -import com.getcode.network.repository.sign -import com.getcode.network.repository.toByteString -import com.getcode.network.repository.toSolanaAccount -import com.getcode.utils.ErrorUtils -import com.getcode.utils.TraceType -import com.getcode.utils.bytes -import com.getcode.utils.trace -import com.google.protobuf.Timestamp -import io.grpc.Status -import io.grpc.StatusRuntimeException -import io.grpc.stub.StreamObserver -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.suspendCancellableCoroutine -import timber.log.Timber -import java.util.UUID -import javax.inject.Inject -import kotlin.coroutines.resume - -/** - * Abstraction layer to handle [ChatApiV2] request results and map to domain models - */ -class ChatServiceV2 @Inject constructor( - private val api: ChatApiV2, - private val chatMapper: ChatMetadataV2Mapper, - private val messageMapper: ChatMessageV2Mapper, - private val pointerMapper: PointerMapper, - private val networkOracle: NetworkOracle, -) { - private fun observeChats(owner: KeyPair): Flow>> { - return networkOracle.managedRequest(api.fetchChats(owner)) - .map { response -> - when (response.result) { - ChatService.GetChatsResponse.Result.OK -> { - Result.success(response.chatsList.map(chatMapper::map)) - } - - ChatService.GetChatsResponse.Result.NOT_FOUND -> { - val error = Throwable("Error: chats not found for owner") - Timber.e(t = error) - Result.failure(error) - } - - ChatService.GetChatsResponse.Result.UNRECOGNIZED -> { - val error = Throwable("Error: Unrecognized request.") - Timber.e(t = error) - Result.failure(error) - } - - else -> { - val error = Throwable("Error: Unknown") - Timber.e(t = error) - Result.failure(error) - } - } - } - } - - @Throws(NoSuchElementException::class) - suspend fun fetchChats(owner: KeyPair): Result> { - return runCatching { observeChats(owner).first() }.getOrDefault(Result.success(emptyList())) - } - - suspend fun setMuteState(owner: KeyPair, chatId: ID, muted: Boolean): Result { - return try { - networkOracle.managedRequest(api.setMuteState(owner, chatId, muted)) - .map { response -> - when (response.result) { - ChatService.SetMuteStateResponse.Result.OK -> { - Result.success(muted) - } - - ChatService.SetMuteStateResponse.Result.CHAT_NOT_FOUND -> { - val error = Throwable("Error: chat not found for $chatId") - Timber.e(t = error) - Result.failure(error) - } - - ChatService.SetMuteStateResponse.Result.CANT_MUTE -> { - val error = Throwable("Error: Unable to change mute state for $chatId.") - Timber.e(t = error) - Result.failure(error) - } - - ChatService.SetMuteStateResponse.Result.UNRECOGNIZED -> { - val error = Throwable("Error: Unrecognized request.") - Timber.e(t = error) - Result.failure(error) - } - - else -> { - val error = Throwable("Error: Unknown") - Timber.e(t = error) - Result.failure(error) - } - } - }.first() - } catch (e: Exception) { - ErrorUtils.handleError(e) - Result.failure(e) - } - } - - suspend fun setSubscriptionState( - owner: KeyPair, - chatId: ID, - subscribed: Boolean, - ): Result { - return try { - networkOracle.managedRequest(api.setSubscriptionState(owner, chatId, subscribed)) - .map { response -> - when (response.result) { - ChatService.SetSubscriptionStateResponse.Result.OK -> { - Result.success(subscribed) - } - - ChatService.SetSubscriptionStateResponse.Result.CHAT_NOT_FOUND -> { - val error = Throwable("Error: chat not found for $chatId") - Timber.e(t = error) - Result.failure(error) - } - - ChatService.SetSubscriptionStateResponse.Result.CANT_UNSUBSCRIBE -> { - val error = Throwable("Error: Unable to change mute state for $chatId.") - Timber.e(t = error) - Result.failure(error) - } - - ChatService.SetSubscriptionStateResponse.Result.UNRECOGNIZED -> { - val error = Throwable("Error: Unrecognized request.") - Timber.e(t = error) - Result.failure(error) - } - - else -> { - val error = Throwable("Error: Unknown") - Timber.e(t = error) - Result.failure(error) - } - } - }.first() - } catch (e: Exception) { - ErrorUtils.handleError(e) - Result.failure(e) - } - } - - suspend fun fetchMessagesFor( - owner: KeyPair, - chat: Chat, - cursor: Cursor? = null, - limit: Int? = null - ): Result> { - return try { - val memberId = chat.members - .filter { it.isSelf } - .map { it.id } - .firstOrNull() - ?: throw IllegalStateException("Fetching messages for a chat you are not a member in") - - networkOracle.managedRequest( - api.fetchChatMessages( - owner, - chat.id, - memberId, - cursor, - limit - ) - ) - .map { response -> - when (response.result) { - ChatService.GetMessagesResponse.Result.OK -> { - Result.success(response.messagesList.map { - messageMapper.map(chat to it) - }) - } - - ChatService.GetMessagesResponse.Result.MESSAGE_NOT_FOUND -> { - val error = - Throwable("Error: messages not found for chat ${chat.id.description}") - Result.failure(error) - } - - ChatService.GetMessagesResponse.Result.UNRECOGNIZED -> { - val error = Throwable("Error: Unrecognized request.") - Result.failure(error) - } - - else -> { - val error = Throwable("Error: Unknown") - Result.failure(error) - } - } - }.first() - } catch (e: Exception) { - ErrorUtils.handleError(e) - Result.failure(e) - } - } - - suspend fun advancePointer( - owner: KeyPair, - chatId: ID, - memberId: UUID, - to: ID, - status: MessageStatus, - ): Result { - - val type = when (status) { - MessageStatus.Sent -> PointerType.SENT - MessageStatus.Delivered -> PointerType.DELIVERED - MessageStatus.Read -> PointerType.READ - MessageStatus.Unknown -> return Result.failure(Throwable("Can't update a pointer to Unknown")) - } - return try { - networkOracle.managedRequest(api.advancePointer(owner, chatId, memberId, to, type)) - .map { response -> - when (response.result) { - ChatService.AdvancePointerResponse.Result.OK -> { - Result.success(Unit) - } - - ChatService.AdvancePointerResponse.Result.CHAT_NOT_FOUND -> { - val error = Throwable("Error: chat not found $chatId") - Timber.e(t = error) - Result.failure(error) - } - - ChatService.AdvancePointerResponse.Result.MESSAGE_NOT_FOUND -> { - val error = Throwable("Error: message not found $to") - Timber.e(t = error) - Result.failure(error) - } - - ChatService.AdvancePointerResponse.Result.UNRECOGNIZED -> { - val error = Throwable("Error: Unrecognized request.") - Timber.e(t = error) - Result.failure(error) - } - - else -> { - val error = Throwable("Error: Unknown") - Timber.e(t = error) - Result.failure(error) - } - } - }.first() - } catch (e: Exception) { - ErrorUtils.handleError(e) - Result.failure(e) - } - } - - suspend fun startChat( - owner: KeyPair, - self: SocialUser, - with: SocialUser, - intentId: ID, - type: ChatType - ): Result { - trace("Creating $type chat for ${intentId.description}") - return when (type) { - ChatType.Unknown -> throw IllegalArgumentException("Unknown chat type provided") - ChatType.Notification -> throw IllegalArgumentException("Unable to create notification chats from client") - ChatType.TwoWay -> { - try { - networkOracle.managedRequest(api.startChat(owner, self, with, intentId)) - .map { response -> - when (response.result) { - ChatService.StartChatResponse.Result.OK -> { - trace("Chat created for ${intentId.description}") - Result.success(chatMapper.map(response.chat)) - } - - ChatService.StartChatResponse.Result.DENIED -> { - val error = Throwable("Error: Denied") - Timber.e(t = error) - Result.failure(error) - } - - ChatService.StartChatResponse.Result.INVALID_PARAMETER -> { - val error = Throwable("Error: Invalid parameter") - Timber.e(t = error) - Result.failure(error) - } - - ChatService.StartChatResponse.Result.UNRECOGNIZED -> { - val error = Throwable("Error: Unrecognized request.") - Timber.e(t = error) - Result.failure(error) - } - - else -> { - val error = Throwable("Error: Unknown") - Timber.e(t = error) - Result.failure(error) - } - } - }.first() - } catch (e: Exception) { - ErrorUtils.handleError(e) - Result.failure(e) - } - } - } - } - - fun openChatStream( - scope: CoroutineScope, - conversation: Conversation, - memberId: UUID, - owner: KeyPair, - chatLookup: (Conversation) -> Chat, - onEvent: (Result) -> Unit - ): ChatMessageStreamReference { - trace("Chat ${conversation.id.description} Opening stream.") - val streamReference = ChatMessageStreamReference(scope) - streamReference.retain() - streamReference.timeoutHandler = { - trace("Chat ${conversation.id.description} Stream timed out") - openChatStream( - conversation = conversation, - memberId = memberId, - owner = owner, - reference = streamReference, - chatLookup = chatLookup, - onEvent = onEvent - ) - } - - openChatStream(conversation, memberId, owner, streamReference, chatLookup, onEvent) - - return streamReference - } - - private fun openChatStream( - conversation: Conversation, - memberId: UUID, - owner: KeyPair, - reference: ChatMessageStreamReference, - chatLookup: (Conversation) -> Chat, - onEvent: (Result) -> Unit - ) { - try { - reference.cancel() - reference.stream = - api.streamChatEvents(object : StreamObserver { - override fun onNext(value: StreamChatEventsResponse?) { - val result = value?.typeCase - if (result == null) { - trace( - message = "Chat ${conversation.id.description} Server sent empty message. This is unexpected.", - type = TraceType.Error - ) - return - } - - when (result) { - StreamChatEventsResponse.TypeCase.EVENTS -> { - val pointerStatuses = value.events.eventsList - .map { it.pointer } - .mapNotNull { pointerMapper.map(it) } - - val messages = value.events.eventsList - .map { it.message } - .map { messageMapper.map(chatLookup(conversation) to it) } - - val isTyping = value.events.eventsList - .map { it.isTyping } - .find { it.isTyping && it.memberId.value.toList().uuid != memberId } != null - - trace("Chat ${conversation.id.description} received ${messages.count()} messages and ${pointerStatuses.count()} status updates.") - val update = ChatStreamEventUpdate(messages, pointerStatuses, isTyping) - onEvent(Result.success(update)) - } - - StreamChatEventsResponse.TypeCase.PING -> { - val stream = reference.stream ?: return - val request = StreamChatEventsRequest.newBuilder() - .setPong( - ClientPong.newBuilder() - .setTimestamp( - Timestamp.newBuilder() - .setSeconds(System.currentTimeMillis() / 1_000) - ) - ).build() - - reference.receivedPing(updatedTimeout = value.ping.pingDelay.seconds * 1_000L) - stream.onNext(request) - trace("Pong Chat ${conversation.id.description} Server timestamp: ${value.ping.timestamp}") - } - - StreamChatEventsResponse.TypeCase.TYPE_NOT_SET -> Unit - StreamChatEventsResponse.TypeCase.ERROR -> { - trace( - type = TraceType.Error, - message = "Chat ${conversation.id.description} hit a snag. ${value.error.code}" - ) - } - } - } - - override fun onError(t: Throwable?) { - val statusException = t as? StatusRuntimeException - if (statusException?.status?.code == Status.Code.UNAVAILABLE) { - trace("Chat ${conversation.id.description} Reconnecting keepalive stream...") - openChatStream( - conversation, - memberId, - owner, - reference, - chatLookup, - onEvent - ) - } else { - t?.printStackTrace() - } - } - - override fun onCompleted() { - - } - }) - - val request = StreamChatEventsRequest.newBuilder() - .setOpenStream(OpenChatEventStream.newBuilder() - .setChatId( - ChatIdV2.newBuilder() - .setValue(conversation.id.toByteString()) - .build() - ) - .setMemberId( - ChatMemberId.newBuilder() - .setValue(memberId.bytes.toByteString()) - ) - .setOwner(owner.publicKeyBytes.toSolanaAccount()) - .apply { setSignature(sign(owner)) } - ).build() - - reference.stream?.onNext(request) - trace("Chat ${conversation.id.description} Initiating a connection...") - } catch (e: Exception) { - if (e is IllegalStateException && e.message == "call already half-closed") { - // ignore - } else { - ErrorUtils.handleError(e) - } - } - } - - suspend fun sendMessage( - owner: KeyPair, - chat: Chat, - memberId: UUID, - content: OutgoingMessageContent, - ): Result = suspendCancellableCoroutine { cont -> - val chatId = chat.id - try { - api.sendMessage( - owner = owner, - chatId = chatId, - memberId = memberId, - content = content, - observer = object : StreamObserver { - override fun onNext(value: SendMessageResponse?) { - val requestResult = value?.result - if (requestResult == null) { - trace( - message = "Chat SendMessage Server returned empty message. This is unexpected.", - type = TraceType.Error - ) - return - } - - val result = when (requestResult) { - SendMessageResponse.Result.OK -> { - trace("Chat message sent =: ${value.message.messageId.value.toList().description}") - val message = messageMapper.map(chat to value.message) - Result.success(message) - } - - SendMessageResponse.Result.DENIED -> { - val error = Throwable("Error: Send Message: Denied") - Timber.e(t = error) - Result.failure(error) - } - - SendMessageResponse.Result.CHAT_NOT_FOUND -> { - val error = Throwable("Error: Send Message: chat not found $chatId") - Timber.e(t = error) - Result.failure(error) - } - - SendMessageResponse.Result.INVALID_CHAT_TYPE -> { - val error = Throwable("Error: Send Message: invalid chat type") - Timber.e(t = error) - Result.failure(error) - } - - SendMessageResponse.Result.INVALID_CONTENT_TYPE -> { - val error = Throwable("Error: Send Message: invalid content type") - Timber.e(t = error) - Result.failure(error) - } - - SendMessageResponse.Result.UNRECOGNIZED -> { - val error = Throwable("Error: Send Message: Unrecognized request.") - Timber.e(t = error) - Result.failure(error) - } - - else -> { - val error = Throwable("Error: Unknown") - Timber.e(t = error) - Result.failure(error) - } - } - - cont.resume(result) - } - - override fun onError(t: Throwable?) { - val error = t ?: Throwable("Error: Hit a snag") - ErrorUtils.handleError(error) - cont.resume(Result.failure(error)) - } - - override fun onCompleted() { - - } - - } - ) - } catch (e: Exception) { - ErrorUtils.handleError(e) - cont.resume(Result.failure(e)) - } - } - - suspend fun revealIdentity( - owner: KeyPair, - chat: Chat, - memberId: UUID, - platform: Platform, - username: String, - ): Result = suspendCancellableCoroutine { cont -> - val chatId = chat.id - try { - api.revealIdentity( - owner, - chatId, - memberId, - platform, - username, - observer = object : StreamObserver { - override fun onNext(value: RevealIdentityResponse?) { - val requestResult = value?.result - if (requestResult == null) { - trace( - message = "Chat SendMessage Server returned empty message. This is unexpected.", - type = TraceType.Error - ) - return - } - - val result = when (requestResult) { - RevealIdentityResponse.Result.OK -> { - trace("Chat message sent =: ${value.message.messageId.value.toList().description}") - val message = messageMapper.map(chat to value.message) - Result.success(message) - } - - RevealIdentityResponse.Result.DENIED -> { - val error = Throwable("Error: Send Message: Denied") - Timber.e(t = error) - Result.failure(error) - } - - RevealIdentityResponse.Result.CHAT_NOT_FOUND -> { - val error = Throwable("Error: Send Message: chat not found $chatId") - Timber.e(t = error) - Result.failure(error) - } - - RevealIdentityResponse.Result.UNRECOGNIZED -> { - val error = Throwable("Error: Send Message: Unrecognized request.") - Timber.e(t = error) - Result.failure(error) - } - - else -> { - val error = Throwable("Error: Unknown") - Timber.e(t = error) - Result.failure(error) - } - } - - cont.resume(result) - } - - override fun onError(t: Throwable?) { - val error = t ?: Throwable("Error: Hit a snag") - ErrorUtils.handleError(error) - cont.resume(Result.failure(error)) - } - - override fun onCompleted() { - - } - - } - ) - } catch (e: Exception) { - ErrorUtils.handleError(e) - cont.resume(Result.failure(e)) - } - } - - suspend fun onStartedTyping( - owner: KeyPair, - chat: Chat, - memberId: UUID, - ): Result = suspendCancellableCoroutine { cont -> - val chatId = chat.id - try { - api.onStartedTyping( - owner, - chatId, - memberId, - observer = object : StreamObserver { - override fun onNext(value: NotifyIsTypingResponse?) { - val requestResult = value?.result - if (requestResult == null) { - trace( - message = "Chat NotifyIsTyping Server returned empty message. This is unexpected.", - type = TraceType.Error - ) - return - } - - val result = when (requestResult) { - NotifyIsTypingResponse.Result.OK -> { - Result.success(Unit) - } - - NotifyIsTypingResponse.Result.DENIED -> { - val error = Throwable("Error: Send Message: Denied") - Timber.e(t = error) - Result.failure(error) - } - - NotifyIsTypingResponse.Result.CHAT_NOT_FOUND -> { - val error = Throwable("Error: Send Message: chat not found $chatId") - Timber.e(t = error) - Result.failure(error) - } - - NotifyIsTypingResponse.Result.UNRECOGNIZED -> { - val error = Throwable("Error: Send Message: Unrecognized request.") - Timber.e(t = error) - Result.failure(error) - } - - else -> { - val error = Throwable("Error: Unknown") - Timber.e(t = error) - Result.failure(error) - } - } - - cont.resume(result) - } - - override fun onError(t: Throwable?) { - val error = t ?: Throwable("Error: Hit a snag") - ErrorUtils.handleError(error) - cont.resume(Result.failure(error)) - } - - override fun onCompleted() { - - } - - } - ) - } catch (e: Exception) { - ErrorUtils.handleError(e) - cont.resume(Result.failure(e)) - } - } - - suspend fun onStoppedTyping( - owner: KeyPair, - chat: Chat, - memberId: UUID, - ): Result = suspendCancellableCoroutine { cont -> - val chatId = chat.id - try { - api.onStoppedTyping( - owner, - chatId, - memberId, - observer = object : StreamObserver { - override fun onNext(value: NotifyIsTypingResponse?) { - val requestResult = value?.result - if (requestResult == null) { - trace( - message = "Chat NotifyIsTyping Server returned empty message. This is unexpected.", - type = TraceType.Error - ) - return - } - - val result = when (requestResult) { - NotifyIsTypingResponse.Result.OK -> { - Result.success(Unit) - } - - NotifyIsTypingResponse.Result.DENIED -> { - val error = Throwable("Error: NotifyIsTyping: Denied") - Timber.e(t = error) - Result.failure(error) - } - - NotifyIsTypingResponse.Result.CHAT_NOT_FOUND -> { - val error = Throwable("Error: NotifyIsTyping: chat not found $chatId") - Timber.e(t = error) - Result.failure(error) - } - - NotifyIsTypingResponse.Result.UNRECOGNIZED -> { - val error = Throwable("Error: NotifyIsTyping: Unrecognized request.") - Timber.e(t = error) - Result.failure(error) - } - - else -> { - val error = Throwable("Error: Unknown") - Timber.e(t = error) - Result.failure(error) - } - } - - cont.resume(result) - } - - override fun onError(t: Throwable?) { - val error = t ?: Throwable("Error: Hit a snag") - ErrorUtils.handleError(error) - cont.resume(Result.failure(error)) - } - - override fun onCompleted() { - - } - - } - ) - } catch (e: Exception) { - ErrorUtils.handleError(e) - cont.resume(Result.failure(e)) - } - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/AssociatedTokenProgram.kt b/api/src/main/java/com/getcode/solana/instructions/programs/AssociatedTokenProgram.kt deleted file mode 100644 index 6762842d2..000000000 --- a/api/src/main/java/com/getcode/solana/instructions/programs/AssociatedTokenProgram.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.getcode.solana.instructions.programs - -import com.getcode.solana.keys.PublicKey -import com.getcode.vendor.Base58 - -class AssociatedTokenProgram { - companion object { - val address = PublicKey(Base58.decode("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL").toList()) - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/TokenProgram.kt b/api/src/main/java/com/getcode/solana/instructions/programs/TokenProgram.kt deleted file mode 100644 index fee746c39..000000000 --- a/api/src/main/java/com/getcode/solana/instructions/programs/TokenProgram.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.getcode.solana.instructions.programs - -import com.getcode.solana.keys.PublicKey -import com.getcode.vendor.Base58 - -class TokenProgram { - companion object { - val address = PublicKey(Base58.decode("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA").toList()) - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/solana/keys/ProgramDerivedAccount.kt b/api/src/main/java/com/getcode/solana/keys/ProgramDerivedAccount.kt deleted file mode 100644 index 7606e160f..000000000 --- a/api/src/main/java/com/getcode/solana/keys/ProgramDerivedAccount.kt +++ /dev/null @@ -1,174 +0,0 @@ -package com.getcode.solana.keys - -import com.getcode.crypt.Sha256Hash -import com.getcode.model.Kin -import com.getcode.solana.organizer.AccountCluster -import org.kin.sdk.base.models.toUTF8Bytes - -data class ProgramDerivedAccount(val publicKey: PublicKey, val bump: Int) - -class TimelockDerivedAccounts( - val owner: PublicKey, - val state: ProgramDerivedAccount, - val vault: ProgramDerivedAccount -) { - companion object { - const val lockoutInDays: Long = 21 - const val dataVersion: Long = 3 - - fun newInstance(owner: PublicKey, legacy: Boolean = false): TimelockDerivedAccounts { - val state: ProgramDerivedAccount - val vault: ProgramDerivedAccount - - if (legacy) { - state = - PublicKey.deriveLegacyTimelockStateAccount(owner = owner, lockout = 1_814_400) - vault = PublicKey.deriveLegacyTimelockVaultAccount(stateAccount = state.publicKey) - } else { - state = PublicKey.deriveTimelockStateAccount(owner = owner, lockout = lockoutInDays) - vault = PublicKey.deriveTimelockVaultAccount( - stateAccount = state.publicKey, - version = dataVersion - ) - } - - return TimelockDerivedAccounts( - owner = owner, - state = state, - vault = vault - ) - } - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as TimelockDerivedAccounts - - if (owner != other.owner) return false - if (state != other.state) return false - if (vault != other.vault) return false - - return true - } - - override fun hashCode(): Int { - var result = owner.hashCode() - result = 31 * result + state.hashCode() - result = 31 * result + vault.hashCode() - return result - } -} - -class SplitterCommitmentAccounts( - val treasury: PublicKey, - val destination: PublicKey, - val recentRoot: Hash, - val transcript: Hash, - - val state: ProgramDerivedAccount, - val vault: ProgramDerivedAccount, -) { - companion object { - fun newInstance( - treasury: PublicKey, - destination: PublicKey, - recentRoot: Hash, - transcript: Hash, - amount: Kin - ): SplitterCommitmentAccounts { - val state = PublicKey.deriveCommitmentStateAccount( - treasury = treasury, - recentRoot = recentRoot, - transcript = transcript, - destination = destination, - amount = amount - ) - - val vault = PublicKey.deriveCommitmentVaultAccount( - treasury = treasury, - commitmentState = state.publicKey - ) - - return SplitterCommitmentAccounts( - treasury = treasury, - destination = destination, - recentRoot = recentRoot, - transcript = transcript, - state = state, - vault = vault, - ) - } - - fun newInstance( - source: AccountCluster, - destination: PublicKey, - amount: Kin, - treasury: PublicKey, - recentRoot: Hash, - intentId: PublicKey, - actionId: Int - ): SplitterCommitmentAccounts { - val transcript = SplitterTranscript( - intentId = intentId, - actionId = actionId, - amount = amount, - source = source.vaultPublicKey, - destination = destination - ) - - return newInstance( - treasury = treasury, - destination = destination, - recentRoot = recentRoot, - transcript = transcript.transcriptHash, - amount = amount - ) - } - } -} - -data class SplitterTranscript( - val intentId: PublicKey, - val actionId: Int, val - amount: Kin, - val source: PublicKey, - val destination: PublicKey -) { - val description = - "receipt[${intentId.base58()}, $actionId]: " + - "transfer ${amount.quarks} quarks " + - "from ${source.base58()} to ${destination.base58()}" - - val transcriptHash: Hash = Hash(Sha256Hash.hash(description.toUTF8Bytes()).toList()) -} - -data class AssociatedTokenAccount( - val owner: PublicKey, - val ata: ProgramDerivedAccount, -) { - companion object { - fun newInstance(owner: PublicKey, mint: Mint): AssociatedTokenAccount { - return AssociatedTokenAccount( - owner = owner, - ata = PublicKey.deriveAssociatedAccount(owner = owner, mint = mint) - ) - } - } -} - -data class PreSwapStateAccount( - val owner: PublicKey, - val state: ProgramDerivedAccount, -) { - companion object { - fun newInstance(owner: PublicKey, source: PublicKey, destination: PublicKey, nonce: PublicKey): PreSwapStateAccount { - return PreSwapStateAccount( - owner = owner, - state = PublicKey.derivePreSwapState(source, destination, nonce) - ) - } - } -} - diff --git a/api/src/main/java/com/getcode/solana/keys/PublicKey.kt b/api/src/main/java/com/getcode/solana/keys/PublicKey.kt deleted file mode 100644 index 54f7c35fc..000000000 --- a/api/src/main/java/com/getcode/solana/keys/PublicKey.kt +++ /dev/null @@ -1,236 +0,0 @@ -package com.getcode.solana.keys - -import com.getcode.crypt.Sha256Hash -import com.getcode.ed25519.Ed25519 -import com.getcode.model.Kin -import com.getcode.network.repository.toPublicKey -import com.getcode.solana.instructions.programs.* -import com.getcode.utils.serializer.PublicKeyAsStringSerializer -import com.getcode.vendor.Base58 -import com.google.protobuf.ByteString -import kotlinx.serialization.Serializable -import org.kin.sdk.base.tools.longToByteArray -import java.io.ByteArrayOutputStream -import java.io.IOException - -@Serializable(with = PublicKeyAsStringSerializer::class) -class PublicKey(bytes: List) : Key32(bytes) { - - constructor(base58: String): this(Base58.decode(base58).toList()) - - companion object { - - val kin: Mint - get() = Mint(Base58.decode("kinXdEcpDQeHPEuQnqmUgtYykqKGVFq6CeVX5iAHJq6").toList()) - - val usdc: Mint - get() = Mint(org.kin.sdk.base.tools.Base58.decode("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v").toList()) - - fun generate(): PublicKey = Ed25519.createSeed32().toPublicKey() - - fun fromBase58(base58: String): PublicKey { - return PublicKey(Base58.decode(base58).toList()) - } - - fun fromByteString(byteString: ByteString): PublicKey { - return PublicKey(byteString.toByteArray().toList()) - } - - fun deriveAssociatedAccount(owner: PublicKey, mint: PublicKey): ProgramDerivedAccount { - return findProgramAddress( - seeds = listOf(owner.bytes.toByteArray(), TokenProgram.address.bytes.toByteArray(), mint.bytes.toByteArray()), - programId = AssociatedTokenProgram.address, - ) - } - - fun deriveTimelockStateAccount( - owner: PublicKey, - lockout: Long - ): ProgramDerivedAccount { - val seeds: List = listOf( - "timelock_state".toByteArray(Charsets.UTF_8), - Mint.kin.bytes.toByteArray(), - timeAuthority.bytes.toByteArray(), - owner.bytes.toByteArray(), - byteArrayOf(lockout.toByte()) - ) - - return findProgramAddress( - seeds = seeds, - programId = TimelockProgram.address, - ) - } - - fun deriveTimelockVaultAccount( - stateAccount: PublicKey, - version: Long - ): ProgramDerivedAccount { - val seeds: List = listOf( - "timelock_vault".toByteArray(Charsets.UTF_8), - stateAccount.bytes.toByteArray(), - byteArrayOf(version.toByte()) - ) - - return findProgramAddress( - seeds = seeds, - programId = TimelockProgram.address, - ) - } - - fun deriveLegacyTimelockStateAccount( - owner: PublicKey, - lockout: Long - ): ProgramDerivedAccount { - val nonce = SystemProgram.address - val version = byteArrayOf(1) - val pdaPadding = SystemProgram.address - - val seeds: List = listOf( - "timelock_state".toByteArray(Charsets.UTF_8), - version, - Mint.kin.bytes.toByteArray(), - subsidizer.bytes.toByteArray(), - nonce.bytes.toByteArray(), - owner.bytes.toByteArray(), - lockout.longToByteArray(), - pdaPadding.bytes.toByteArray() - ) - - return findProgramAddress( - seeds = seeds, - programId = TimelockProgram.legacyAddress, - ) - } - - fun deriveLegacyTimelockVaultAccount( - stateAccount: PublicKey - ): ProgramDerivedAccount { - val seeds: List = listOf( - "timelock_vault".toByteArray(Charsets.UTF_8), - stateAccount.bytes.toByteArray(), - byteArrayOf(0) - ) - - return findProgramAddress( - seeds = seeds, - programId = TimelockProgram.legacyAddress, - ) - } - - /// FindProgramAddress mirrors the implementation of the Solana SDK's FindProgramAddress. Its primary - /// use case (for Kin and Agora) is for deriving associated accounts. - /// - /// Reference: https://github.com/solana-labs/solana/blob/5548e599fe4920b71766e0ad1d121755ce9c63d5/sdk/program/src/pubkey.rs#L234 - /// - fun findProgramAddress( - seeds: List, - programId: PublicKey - ): ProgramDerivedAccount { - for (i in 0..255) { - val bumpValue = 255 - i - try { - val publicKey = deriveProgramAddress(programId, listOf(*seeds.toTypedArray(), byteArrayOf(bumpValue.toByte()))) - return ProgramDerivedAccount(publicKey, bumpValue) - } catch (e: RuntimeException) { - //no-op - } - } - - throw Exception("Unable to find a viable program address nonce") - } - - /// CreateProgramAddress mirrors the implementation of the Solana SDK's CreateProgramAddress. - /// - /// ProgramAddresses are public keys that _do not_ lie on the ed25519 curve to ensure that - /// there is no associated private key. In the event that the program and seed parameters - /// result in a valid public key, ErrInvalidPublicKey is returned. - /// - /// Reference: https://github.com/solana-labs/solana/blob/5548e599fe4920b71766e0ad1d121755ce9c63d5/sdk/program/src/pubkey.rs#L158 - /// - fun deriveProgramAddress(programId: PublicKey, seeds: List): PublicKey { - fun getMaxSeeds() = 16 - - val buffer = ByteArrayOutputStream() - require(seeds.size < getMaxSeeds()) { "Max seed size exceeded" } - - for (seed in seeds) { - try { - buffer.write(seed) - } catch (e: IOException) { - throw RuntimeException(e) - } - } - try { - buffer.write(programId.bytes.toByteArray()) - buffer.write("ProgramDerivedAddress".toByteArray()) - } catch (e: IOException) { - throw RuntimeException(e) - } - val hash = Sha256Hash.hash(buffer.toByteArray()) - - val publicKey = PublicKey(hash.toList()) - - // Following the Solana SDK, we want to _reject_ the generated public key - // if it's a valid compressed EdwardsPoint (on the curve). - // - if (Ed25519.onCurve(publicKey.bytes.toByteArray())) { - throw RuntimeException("Invalid seeds, address must fall off the curve") - } - - return PublicKey(hash.toList()) - } - - fun deriveCommitmentStateAccount(treasury: PublicKey, recentRoot: Hash, transcript: Hash, destination: PublicKey, amount: Kin): ProgramDerivedAccount { - return findProgramAddress( - programId = splitter, - seeds = listOf( - "commitment_state".toByteArray(Charsets.UTF_8), - treasury.bytes.toByteArray(), - recentRoot.bytes.toByteArray(), - transcript.bytes.toByteArray(), - destination.bytes.toByteArray(), - amount.quarks.longToByteArray() - ) - ) - } - - fun deriveCommitmentVaultAccount(treasury: PublicKey, commitmentState: PublicKey): ProgramDerivedAccount { - return findProgramAddress( - programId = splitter, - seeds = listOf( - "commitment_vault".toByteArray(Charsets.UTF_8), - treasury.bytes.toByteArray(), - commitmentState.bytes.toByteArray() - ) - ) - } - - fun derivePreSwapState( - source: PublicKey, destination: PublicKey, nonce: PublicKey - ): ProgramDerivedAccount { - return findProgramAddress( - programId = SwapValidatorProgram.address, - seeds = listOf( - "pre_swap_state".toByteArray(Charsets.UTF_8), - source.bytes.toByteArray(), - destination.bytes.toByteArray(), - nonce.bytes.toByteArray(), - ) - ) - } - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - - other as PublicKey - return size == other.size && bytes == other.bytes - } - - override fun hashCode(): Int { - var result = super.hashCode() - result = 31 * result + size - return result - } - -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/utils/Timber.kt b/api/src/main/java/com/getcode/utils/Timber.kt deleted file mode 100644 index 20c188bc5..000000000 --- a/api/src/main/java/com/getcode/utils/Timber.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.getcode.utils - -import com.getcode.api.BuildConfig -import timber.log.Timber -import kotlin.math.roundToLong - -fun timberTimer(message: String, block: () -> T): T { - return if (BuildConfig.DEBUG) { - // Prefer nanoTime over currentTimeMillis for measuring elapsed time because it does not try to - // align with wall-time. - val start = System.nanoTime() - val result = block() - Timber.d( - "$message took ${ - (System.nanoTime() - start).toDouble().div(1_000_000).roundToLong() - }ms", - ) - result - } else { - block() - } -} - -suspend fun timberTimerSuspend(message: String, block: suspend () -> T): T { - return if (BuildConfig.DEBUG) { - // Prefer nanoTime over currentTimeMillis for measuring elapsed time because it does not try to - // align with wall-time. - val start = System.nanoTime() - val result = block() - Timber.d( - "$message took ${ - (System.nanoTime() - start).toDouble().div(1_000_000).roundToLong() - }ms", - ) - result - } else { - block() - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/utils/serializer/ConversationMessageContentTypeSerializer.kt b/api/src/main/java/com/getcode/utils/serializer/ConversationMessageContentTypeSerializer.kt deleted file mode 100644 index d9b893d06..000000000 --- a/api/src/main/java/com/getcode/utils/serializer/ConversationMessageContentTypeSerializer.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.getcode.utils.serializer - -import com.getcode.model.chat.MessageContent -import kotlinx.serialization.KSerializer -import kotlinx.serialization.decodeFromString -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 kotlinx.serialization.json.Json - -object MessageContentSerializer : KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CMC", PrimitiveKind.STRING) - - override fun deserialize(decoder: Decoder): MessageContent { - val string = decoder.decodeString() - return Json.decodeFromString(string) - } - - override fun serialize(encoder: Encoder, value: MessageContent) { - val payload = Json.encodeToString(MessageContent.serializer(), value) - encoder.encodeString(payload) - } - -} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c1b817aa5..af46b23dd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,21 +20,19 @@ plugins { val contributorsSigningConfig = ContributorsSignatory(rootProject) android { - namespace = Android.namespace + // static namespace + namespace = Android.codeNamespace compileSdk = Android.compileSdkVersion defaultConfig { - applicationId = Android.namespace versionCode = versioning.getVersionCode() - versionName = Packaging.versionName - + versionName = Packaging.Code.versionName + applicationId = Android.codeNamespace minSdk = Android.minSdkVersion targetSdk = Android.targetSdkVersion buildToolsVersion = Android.buildToolsVersion testInstrumentationRunner = Android.testInstrumentationRunner - resValue("string", "applicationId", Android.namespace) - buildConfigField("String", "MIXPANEL_API_KEY", "\"${tryReadProperty(rootProject.rootDir, "MIXPANEL_API_KEY")}\"") buildConfigField("String", "KADO_API_KEY", "\"${tryReadProperty(rootProject.rootDir, "KADO_API_KEY")}\"") buildConfigField("Boolean", "NOTIFY_ERRORS", "false") @@ -60,12 +58,14 @@ android { buildTypes { getByName("release") { + resValue("string", "applicationId", Android.codeNamespace) isMinifyEnabled = true isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } getByName("debug") { applicationIdSuffix = ".dev" + resValue("string", "applicationId", "${Android.codeNamespace}.dev") signingConfig = signingConfigs.getByName("contributors") val debugMinifyEnabled = tryReadProperty(rootProject.rootDir, "DEBUG_MINIFY", "false").toBooleanLenient() ?: false @@ -100,8 +100,6 @@ android { kotlinOptions { jvmTarget = Versions.java freeCompilerArgs += listOf( - "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", - "-Xuse-experimental=kotlinx.coroutines.FlowPreview", "-opt-in=kotlin.RequiresOptIn" ) } @@ -115,12 +113,24 @@ android { dependencies { implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) - implementation(project(":api")) - implementation(project(":crypto:ed25519")) - implementation(project(":crypto:kin")) - implementation(project(":common:components")) - implementation(project(":common:resources")) - implementation(project(":common:theme")) + // libs (not included with services) + implementation(project(":libs:locale")) + implementation(project(":libs:vibrator")) + implementation(project(":libs:messaging")) + implementation(project(":libs:permissions")) + implementation(project(":libs:quickresponse")) + implementation(project(":libs:requests")) + + // code services + implementation(project(":services:code")) + + // ui components + implementation(project(":ui:components")) + implementation(project(":ui:navigation")) + implementation(project(":ui:resources")) + implementation(project(":ui:theme")) + + // tipkit implementation(project(":vendor:tipkit:tipkit-m2")) coreLibraryDesugaring(Libs.android_desugaring) @@ -128,7 +138,6 @@ dependencies { //standard libraries implementation(Libs.kotlinx_collections_immutable) implementation(Libs.kotlinx_serialization_json) - implementation(Libs.kotlinx_datetime) implementation(Libs.androidx_core) implementation(Libs.androidx_constraint_layout) implementation(Libs.androidx_lifecycle_runtime) @@ -138,7 +147,7 @@ dependencies { //hilt dependency injection implementation(Libs.hilt) - implementation("androidx.webkit:webkit:1.11.0") + implementation("androidx.webkit:webkit:1.12.1") kapt(Libs.hilt_android_compiler) kapt(Libs.hilt_compiler) androidTestImplementation(Libs.hilt) @@ -152,8 +161,6 @@ dependencies { //Jetpack compose implementation(platform(Libs.compose_bom)) implementation(Libs.compose_ui) - debugImplementation(Libs.compose_ui_tools) - implementation(Libs.compose_ui_tools_preview) implementation(Libs.compose_accompanist) implementation(Libs.compose_foundation) implementation(Libs.compose_material) @@ -163,10 +170,6 @@ dependencies { implementation(Libs.compose_livedata) implementation(Libs.compose_navigation) implementation(Libs.compose_paging) - implementation(Libs.compose_voyager_navigation) - implementation(Libs.compose_voyager_navigation_transitions) - implementation(Libs.compose_voyager_navigation_bottomsheet) - implementation(Libs.compose_voyager_navigation_hilt) implementation(Libs.compose_webview) implementation(Libs.androidx_biometrics) @@ -185,9 +188,6 @@ dependencies { implementation(Libs.androidx_browser) implementation(Libs.androidx_constraint_layout_compose) - implementation(Libs.rxjava) - implementation(Libs.rxandroid) - implementation(Libs.slf4j) implementation(Libs.grpc_android) @@ -199,15 +199,12 @@ dependencies { implementation(Libs.hilt_nav_compose) implementation(Libs.lib_phone_number_port) implementation(Libs.mp_android_chart) - implementation(Libs.zxing) implementation(Libs.mixpanel) implementation(Libs.retrofit) implementation(Libs.retrofit_converter) implementation(Libs.okhttp_logging_interceptor) - implementation(Libs.cloudy) - androidTestImplementation(Libs.androidx_test_runner) androidTestImplementation(Libs.androidx_junit) androidTestImplementation(Libs.junit) @@ -233,4 +230,4 @@ dependencies { implementation(Libs.bugsnag) implementation(Libs.haze) -} +} \ No newline at end of file diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..16c292d86 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/debug/res/drawable/ic_launcher_background.xml b/app/src/debug/res/drawable/ic_launcher_background.xml deleted file mode 100644 index a40ec6210..000000000 --- a/app/src/debug/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/debug/res/values/ic_launcher_background.xml b/app/src/debug/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..01e1320a6 --- /dev/null +++ b/app/src/debug/res/values/ic_launcher_background.xml @@ -0,0 +1,5 @@ + + + #9C0222 + + diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml index 055e62a1f..1fa80e5c0 100644 --- a/app/src/debug/res/values/strings.xml +++ b/app/src/debug/res/values/strings.xml @@ -1,3 +1,4 @@ Code Dev + com.getcode.dev.accountprovider \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 76566fe3f..52f454aee 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,13 +11,10 @@ - - - diff --git a/app/src/main/java/com/getcode/AccountProvider.kt b/app/src/main/java/com/getcode/AccountProvider.kt new file mode 100644 index 000000000..580aca624 --- /dev/null +++ b/app/src/main/java/com/getcode/AccountProvider.kt @@ -0,0 +1,57 @@ +package com.getcode + +import android.accounts.AccountManager +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import com.getcode.util.AccountUtils +import kotlinx.coroutines.runBlocking + +class AccountProvider : ContentProvider() { + + override fun onCreate(): Boolean { + + return true + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor? { + val context = context ?: return null + val token = runBlocking { AccountUtils.getToken(context) } + val cursor = MatrixCursor(arrayOf(AccountManager.KEY_AUTHTOKEN)) + cursor.addRow(arrayOf(token)) + return cursor + } + + override fun insert(uri: Uri, values: ContentValues?): Uri? { + // Handle insertion of new data if needed + return null + } + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { + // Handle deletion of data if needed + return 0 + } + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array? + ): Int { + // Handle updating of data if needed + return 0 + } + + override fun getType(uri: Uri): String { + // Return the MIME type of data based on the URI pattern + return "vnd.android.cursor.dir/vnd.getcode.account" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/CodeApp.kt b/app/src/main/java/com/getcode/CodeApp.kt index d6b1cc339..0c98629a8 100644 --- a/app/src/main/java/com/getcode/CodeApp.kt +++ b/app/src/main/java/com/getcode/CodeApp.kt @@ -3,7 +3,6 @@ package com.getcode import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -17,13 +16,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey import cafe.adriel.voyager.core.stack.StackEvent import cafe.adriel.voyager.navigator.CurrentScreen import cafe.adriel.voyager.navigator.Navigator @@ -35,18 +32,19 @@ import com.getcode.navigation.core.CombinedNavigator import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.LoginScreen import com.getcode.navigation.transitions.SheetSlideTransition -import com.getcode.theme.CodeTheme import com.getcode.theme.LocalCodeColors import com.getcode.ui.components.AuthCheck import com.getcode.ui.components.bars.BottomBarContainer -import com.getcode.ui.components.CodeScaffold +import com.getcode.ui.theme.CodeScaffold import com.getcode.ui.components.ModalContainer import com.getcode.ui.components.OnLifecycleEvent -import com.getcode.ui.components.TitleBar +import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.bars.TopBarContainer import com.getcode.ui.modals.ConfirmationModals import com.getcode.ui.utils.getActivity -import com.getcode.ui.utils.getActivityScopedViewModel +import com.getcode.navigation.extensions.getActivityScopedViewModel +import com.getcode.ui.LocalTopBarPadding +import com.getcode.ui.theme.CodeTheme import com.getcode.ui.utils.measured import com.getcode.ui.utils.rememberBiometricsState import com.getcode.util.BiometricsError @@ -56,7 +54,7 @@ import dev.bmcreations.tipkit.engines.TipsEngine @Composable fun CodeApp(tipsEngine: TipsEngine) { - val tlvm = MainRoot.getActivityScopedViewModel() + val tlvm = getActivityScopedViewModel() val state by tlvm.state.collectAsState() val activity = LocalContext.current.getActivity() val biometricsState = rememberBiometricsState( @@ -100,9 +98,10 @@ fun CodeApp(tipsEngine: TipsEngine) { val (isVisibleTopBar, isVisibleBackButton) = appState.isVisibleTopBar if (isVisibleTopBar && appState.currentTitle.isNotBlank()) { - TitleBar( + AppBarWithTitle( modifier = Modifier.measured { topBarHeight = it.height }, title = appState.currentTitle, + titleAlignment = Alignment.CenterHorizontally, backButton = isVisibleBackButton, onBackIconClicked = appState::upPress ) @@ -156,8 +155,8 @@ fun CodeApp(tipsEngine: TipsEngine) { } } BiometricsBlockingView(modifier = Modifier.fillMaxSize(), biometricsState) - TopBarContainer(appState) - BottomBarContainer(appState) + TopBarContainer(appState.barMessages) + BottomBarContainer(appState.barMessages) ConfirmationModals(Modifier.fillMaxSize()) } } @@ -181,7 +180,8 @@ private fun AppNavHost(content: @Composable () -> Unit) { } } - } + }, + onHide = com.getcode.services.manager.ModalManager::clear ) { sheetNav -> combinedNavigator = combinedNavigator?.apply { sheetNavigator = sheetNav } ?: CombinedNavigator(sheetNav) @@ -206,19 +206,3 @@ private fun CrossfadeTransition( transition = { fadeIn() togetherWith fadeOut() } ) } - -internal data object MainRoot : Screen { - - override val key: ScreenKey = uniqueScreenKey - - private fun readResolve(): Any = this - - @Composable - override fun Content() { - Box( - modifier = Modifier - .fillMaxSize() - .background(CodeTheme.colors.background) - ) - } -} diff --git a/app/src/main/java/com/getcode/CodeAppState.kt b/app/src/main/java/com/getcode/CodeAppState.kt index 657eec04d..0218d83e3 100644 --- a/app/src/main/java/com/getcode/CodeAppState.kt +++ b/app/src/main/java/com/getcode/CodeAppState.kt @@ -6,15 +6,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import com.getcode.manager.BottomBarManager -import com.getcode.manager.ModalManager -import com.getcode.manager.TopBarManager +import com.getcode.services.manager.ModalManager import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.AccessKeyLoginScreen import com.getcode.navigation.screens.LoginPhoneVerificationScreen import com.getcode.navigation.screens.LoginScreen import com.getcode.navigation.screens.NamedScreen +import com.getcode.ui.components.bars.BarManager +import com.getcode.ui.components.bars.BarMessages +import com.getcode.ui.components.bars.rememberBarManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -26,10 +27,11 @@ import kotlinx.coroutines.launch fun rememberCodeAppState( scaffoldState: ScaffoldState = rememberScaffoldState(), navigator: CodeNavigator = LocalCodeNavigator.current, + barManager: BarManager = rememberBarManager(), coroutineScope: CoroutineScope = rememberCoroutineScope() ) = - remember(scaffoldState, navigator , coroutineScope) { - CodeAppState(scaffoldState, navigator, coroutineScope) + remember(scaffoldState, navigator , barManager, coroutineScope) { + CodeAppState(scaffoldState, navigator, barManager, coroutineScope) } /** @@ -39,21 +41,12 @@ fun rememberCodeAppState( class CodeAppState( val scaffoldState: ScaffoldState, var navigator: CodeNavigator, + private val barManager: BarManager, coroutineScope: CoroutineScope ) { init { coroutineScope.launch { - TopBarManager.messages.collect { currentMessages -> - topBarMessage.value = currentMessages.firstOrNull() - } - } - coroutineScope.launch { - BottomBarManager.messages.collect { currentMessages -> - bottomBarMessage.value = currentMessages.firstOrNull() - } - } - coroutineScope.launch { - ModalManager.messages.collect { currentMessages -> + com.getcode.services.manager.ModalManager.messages.collect { currentMessages -> modalMessage.value = currentMessages.firstOrNull() } } @@ -88,9 +81,10 @@ class CodeAppState( ) } - val topBarMessage = MutableStateFlow(null) - val bottomBarMessage = MutableStateFlow(null) - val modalMessage = MutableStateFlow(null) + val barMessages: BarMessages + get() = barManager.barMessages + + val modalMessage = MutableStateFlow(null) fun upPress() { if (navigator.pop().not()) { diff --git a/app/src/main/java/com/getcode/Locals.kt b/app/src/main/java/com/getcode/Locals.kt index c4b9541ee..4e0aa67c3 100644 --- a/app/src/main/java/com/getcode/Locals.kt +++ b/app/src/main/java/com/getcode/Locals.kt @@ -1,29 +1,19 @@ package com.getcode -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.painter.BitmapPainter import com.getcode.analytics.AnalyticsService import com.getcode.analytics.AnalyticsServiceNull -import com.getcode.network.exchange.Exchange -import com.getcode.network.exchange.ExchangeNull import com.getcode.network.repository.BetaOptions import com.getcode.ui.utils.BiometricsState -import com.getcode.util.CurrencyUtils import com.getcode.util.DeeplinkHandler import com.getcode.util.PhoneUtils -import com.getcode.utils.network.NetworkConnectivityListener -import com.getcode.utils.network.NetworkObserverStub val LocalSession: ProvidableCompositionLocal = staticCompositionLocalOf { null } val LocalAnalytics: ProvidableCompositionLocal = staticCompositionLocalOf { AnalyticsServiceNull() } -val LocalNetworkObserver: ProvidableCompositionLocal = staticCompositionLocalOf { NetworkObserverStub() } val LocalPhoneFormatter: ProvidableCompositionLocal = staticCompositionLocalOf { null } -val LocalCurrencyUtils: ProvidableCompositionLocal = staticCompositionLocalOf { null } -val LocalExchange: ProvidableCompositionLocal = staticCompositionLocalOf { ExchangeNull() } val LocalDeeplinks: ProvidableCompositionLocal = staticCompositionLocalOf { null } -val LocalTopBarPadding: ProvidableCompositionLocal = staticCompositionLocalOf { PaddingValues() } val LocalBetaFlags: ProvidableCompositionLocal = staticCompositionLocalOf { BetaOptions.Defaults } val LocalDownloadQrCode: ProvidableCompositionLocal = staticCompositionLocalOf { null } val LocalBiometricsState: ProvidableCompositionLocal = staticCompositionLocalOf { BiometricsState() } diff --git a/app/src/main/java/com/getcode/MainRoot.kt b/app/src/main/java/com/getcode/MainRoot.kt new file mode 100644 index 000000000..19fbb200a --- /dev/null +++ b/app/src/main/java/com/getcode/MainRoot.kt @@ -0,0 +1,30 @@ +package com.getcode + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import com.getcode.navigation.screens.ScanScreen +import com.getcode.theme.CodeTheme + +internal data object MainRoot : Screen { + + override val key: ScreenKey = uniqueScreenKey + + private fun readResolve(): Any = this + + @Composable + override fun Content() { + Box( + modifier = Modifier + .fillMaxSize() + .background(CodeTheme.colors.background) + ) + } +} + +typealias AppHomeScreen = ScanScreen \ No newline at end of file diff --git a/app/src/main/java/com/getcode/SessionController.kt b/app/src/main/java/com/getcode/SessionController.kt index 53512c210..0b6062582 100644 --- a/app/src/main/java/com/getcode/SessionController.kt +++ b/app/src/main/java/com/getcode/SessionController.kt @@ -14,32 +14,22 @@ import com.getcode.analytics.AnalyticsManager import com.getcode.analytics.AnalyticsService import com.getcode.domain.CashLinkManager import com.getcode.manager.AuthManager -import com.getcode.manager.BottomBarManager import com.getcode.manager.GiftCardManager -import com.getcode.manager.MnemonicManager -import com.getcode.manager.ModalManager import com.getcode.manager.SessionManager -import com.getcode.manager.TopBarManager import com.getcode.model.BuyModuleFeature import com.getcode.model.CameraGesturesFeature -import com.getcode.model.CodePayload import com.getcode.model.Currency -import com.getcode.model.Domain import com.getcode.model.Feature -import com.getcode.model.Fiat import com.getcode.model.FlippableTipCardFeature import com.getcode.model.GalleryFeature -import com.getcode.model.ID import com.getcode.model.IntentMetadata import com.getcode.model.InvertedDragZoomFeature import com.getcode.model.Kin import com.getcode.model.KinAmount -import com.getcode.model.Kind -import com.getcode.model.PrefsBool +import com.getcode.services.model.PrefsBool import com.getcode.model.RequestKinFeature import com.getcode.model.SocialUser import com.getcode.model.TwitterUser -import com.getcode.model.Username import com.getcode.model.notifications.NotificationType import com.getcode.models.Bill import com.getcode.models.BillState @@ -47,13 +37,12 @@ import com.getcode.models.BillToast import com.getcode.models.ConfirmationState import com.getcode.models.DeepLinkRequest import com.getcode.models.LoginConfirmation -import com.getcode.models.PaymentConfirmation +import com.getcode.models.PrivatePaymentConfirmation import com.getcode.models.PaymentValuation import com.getcode.models.SocialUserPaymentConfirmation import com.getcode.models.amountFloored import com.getcode.network.BalanceController import com.getcode.network.NotificationCollectionHistoryController -import com.getcode.network.ChatHistoryController import com.getcode.network.TipController import com.getcode.network.client.Client import com.getcode.network.client.RemoteSendException @@ -77,26 +66,30 @@ import com.getcode.network.repository.PaymentRepository import com.getcode.network.repository.PrefRepository import com.getcode.network.repository.ReceiveTransactionRepository import com.getcode.network.repository.StatusRepository -import com.getcode.network.repository.hexEncodedString -import com.getcode.network.repository.toPublicKey +import com.getcode.util.IntentUtils +import com.getcode.utils.Kin +import com.getcode.extensions.formatted +import com.getcode.manager.BottomBarManager +import com.getcode.manager.TopBarManager +import com.getcode.services.model.CodePayload +import com.getcode.model.Domain +import com.getcode.services.model.Kind +import com.getcode.services.model.payload.Username +import com.getcode.model.toPublicKey +import com.getcode.services.utils.catchSafely +import com.getcode.services.utils.nonce import com.getcode.solana.organizer.GiftCardAccount import com.getcode.solana.organizer.Organizer -import com.getcode.ui.components.PermissionResult -import com.getcode.util.CurrencyUtils -import com.getcode.util.IntentUtils -import com.getcode.util.Kin -import com.getcode.util.formatted +import com.getcode.ui.components.restrictions.RestrictionType import com.getcode.util.permissions.PermissionChecker +import com.getcode.util.permissions.PermissionResult import com.getcode.util.resources.ResourceHelper import com.getcode.util.showNetworkError import com.getcode.util.vibration.Vibrator import com.getcode.utils.ErrorUtils import com.getcode.utils.TraceType -import com.getcode.utils.catchSafely -import com.getcode.utils.network.NetworkConnectivityListener -import com.getcode.utils.nonce +import com.getcode.utils.hexEncodedString import com.getcode.utils.trace -import com.getcode.vendor.Base58 import com.getcode.view.main.scanner.UiElement import com.kik.kikx.kikcodes.implementation.KikCodeAnalyzer import com.kik.kikx.models.ScannableKikCode @@ -104,14 +97,12 @@ import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy @@ -127,6 +118,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import org.kin.sdk.base.tools.Base58 import timber.log.Timber import java.util.Timer import java.util.TimerTask @@ -179,13 +171,7 @@ sealed interface SessionEvent { data object PresentTipEntry : SessionEvent data object RequestNotificationPermissions : SessionEvent data class SendIntent(val intent: Intent) : SessionEvent - data class OnChatPaidForSuccessfully(val intentId: ID, val user: SocialUser): SessionEvent -} - -enum class RestrictionType { - ACCESS_EXPIRED, - FORCE_UPGRADE, - TIMELOCK_UNLOCKED + data class OnChatPaidForSuccessfully(val intentId: com.getcode.model.ID, val user: SocialUser): SessionEvent } @SuppressLint("CheckResult") @@ -196,22 +182,22 @@ class SessionController @Inject constructor( private val paymentRepository: PaymentRepository, private val balanceController: BalanceController, private val historyController: NotificationCollectionHistoryController, - private val chatHistoryController: ChatHistoryController, private val tipController: TipController, private val prefRepository: PrefRepository, private val analytics: AnalyticsService, private val authManager: AuthManager, - private val networkObserver: NetworkConnectivityListener, + private val networkObserver: com.getcode.utils.network.NetworkConnectivityListener, private val resources: ResourceHelper, private val vibrator: Vibrator, - private val currencyUtils: CurrencyUtils, + private val currencyUtils: com.getcode.utils.CurrencyUtils, private val exchange: Exchange, private val giftCardManager: GiftCardManager, - private val mnemonicManager: MnemonicManager, + private val mnemonicManager: com.getcode.services.manager.MnemonicManager, private val cashLinkManager: CashLinkManager, private val permissionChecker: PermissionChecker, private val notificationManager: NotificationManagerCompat, private val codeAnalyzer: KikCodeAnalyzer, + private val sessionManager: SessionManager, appSettings: AppSettingsRepository, betaFlagsRepository: BetaFlagsRepository, features: FeatureRepository, @@ -405,13 +391,6 @@ class SessionController @Inject constructor( state.update { it.copy(notificationUnreadCount = count) } }.launchIn(scope) - chatHistoryController.unreadCount - .distinctUntilChanged() - .map { it } - .onEach { count -> - state.update { it.copy(chatUnreadCount = count) } - }.launchIn(scope) - prefRepository.observeOrDefault(PrefsBool.LOG_SCAN_TIMES, false) .flowOn(Dispatchers.IO) .onEach { log -> @@ -463,10 +442,6 @@ class SessionController @Inject constructor( UiElement.GET_KIN } - if (betaOptions.conversationsEnabled) { - actions += UiElement.CHAT - } - actions += UiElement.BALANCE return actions @@ -559,7 +534,6 @@ class SessionController @Inject constructor( } private fun presentSend(data: List, bill: Bill, isVibrate: Boolean = false) { - println("present send") if (bill.didReceive) { state.update { val billState = it.billState @@ -867,8 +841,8 @@ class SessionController @Inject constructor( if (show) { delay(400) - ModalManager.showMessage( - ModalManager.Message( + com.getcode.services.manager.ModalManager.showMessage( + com.getcode.services.manager.ModalManager.Message( icon = R.drawable.ic_bell, title = resources.getString(R.string.title_turnOnNotifications), subtitle = resources.getString(R.string.subtitle_turnOnNotifications), @@ -1146,7 +1120,7 @@ class SessionController @Inject constructor( if (payload != null) { code = payload } else { - val fiat = Fiat(currency = amount.rate.currency, amount = amount.fiat) + val fiat = com.getcode.model.Fiat(currency = amount.rate.currency, amount = amount.fiat) code = CodePayload( kind = Kind.RequestPayment, @@ -1173,7 +1147,7 @@ class SessionController @Inject constructor( if (isReceived) { billState = billState.copy( - paymentConfirmation = PaymentConfirmation( + privatePaymentConfirmation = PrivatePaymentConfirmation( state = ConfirmationState.AwaitingConfirmation, payload = code, requestedAmount = amount, @@ -1207,12 +1181,12 @@ class SessionController @Inject constructor( // keep bill active while sending cashLinkManager.cancelBillTimeout() - val paymentConfirmation = state.value.billState.paymentConfirmation ?: return@launch + val paymentConfirmation = state.value.billState.privatePaymentConfirmation ?: return@launch state.update { val billState = it.billState it.copy( billState = billState.copy( - paymentConfirmation = paymentConfirmation.copy(state = ConfirmationState.Sending) + privatePaymentConfirmation = paymentConfirmation.copy(state = ConfirmationState.Sending) ), ) } @@ -1226,11 +1200,11 @@ class SessionController @Inject constructor( state.update { val billState = it.billState - val confirmation = it.billState.paymentConfirmation ?: return@update it + val confirmation = it.billState.privatePaymentConfirmation ?: return@update it it.copy( billState = billState.copy( - paymentConfirmation = confirmation.copy(state = ConfirmationState.Sent), + privatePaymentConfirmation = confirmation.copy(state = ConfirmationState.Sent), ), ) } @@ -1253,7 +1227,7 @@ class SessionController @Inject constructor( billState = uiModel.billState.copy( bill = null, showToast = false, - paymentConfirmation = null, + privatePaymentConfirmation = null, toast = null, valuation = null, primaryAction = null, @@ -1265,7 +1239,7 @@ class SessionController @Inject constructor( } private fun cancelPayment(rejected: Boolean, ignoreRedirect: Boolean = false) { - val paymentRendezous = state.value.billState.paymentConfirmation + val paymentRendezous = state.value.billState.privatePaymentConfirmation val bill = state.value.billState.bill ?: return val amount = bill.amount val request = bill.metadata.request @@ -1284,7 +1258,7 @@ class SessionController @Inject constructor( presentationStyle = PresentationStyle.Slide, billState = it.billState.copy( bill = null, - paymentConfirmation = null, + privatePaymentConfirmation = null, valuation = null, primaryAction = null, secondaryAction = null, @@ -1325,7 +1299,7 @@ class SessionController @Inject constructor( } fun rejectPayment(ignoreRedirect: Boolean = false) { - val payload = state.value.billState.paymentConfirmation?.payload + val payload = state.value.billState.privatePaymentConfirmation?.payload cancelPayment(true, ignoreRedirect) payload ?: return @@ -1450,7 +1424,7 @@ class SessionController @Inject constructor( billState = it.billState.copy( bill = null, showToast = false, - paymentConfirmation = null, + privatePaymentConfirmation = null, loginConfirmation = null, toast = null, valuation = null, @@ -1725,8 +1699,8 @@ class SessionController @Inject constructor( cancelRemoteSend(giftCard, amount) cancelSend(style = PresentationStyle.Slide) }, - onClose = { - if (it == null) { + onClose = { fromAction -> + if (!fromAction) { cancelSend(style = PresentationStyle.Pop) vibrator.vibrate() } @@ -1743,6 +1717,7 @@ class SessionController @Inject constructor( if (request != null) { when { request.paymentRequest != null -> { + val payment = request.paymentRequest!! scope.launch { if (state.value.balance == null) { balanceController.fetchBalanceSuspend() @@ -1753,9 +1728,9 @@ class SessionController @Inject constructor( it.copy(balance = amount) } } - val fiat = request.paymentRequest.fiat + val fiat = payment.fiat val kind = - if (request.paymentRequest.fees.isEmpty()) Kind.RequestPayment else Kind.RequestPaymentV2 + if (payment.fees.isEmpty()) Kind.RequestPayment else Kind.RequestPaymentV2 val payload = CodePayload( kind = kind, value = fiat, @@ -1789,9 +1764,10 @@ class SessionController @Inject constructor( } request.tipRequest != null -> { + val tip = request.tipRequest!! val payload = CodePayload( kind = Kind.Tip, - value = Username(request.tipRequest.username) + value = Username(tip.username) ) if (scannedRendezvous.contains(payload.rendezvous.publicKey)) { @@ -1805,7 +1781,8 @@ class SessionController @Inject constructor( } request.imageRequest != null -> { - onImageSelected(request.imageRequest.uri) + val image = request.imageRequest!! + onImageSelected(image.uri) } } } diff --git a/app/src/main/java/com/getcode/TopLevelViewModel.kt b/app/src/main/java/com/getcode/TopLevelViewModel.kt index 5bceb4067..c6816d19d 100644 --- a/app/src/main/java/com/getcode/TopLevelViewModel.kt +++ b/app/src/main/java/com/getcode/TopLevelViewModel.kt @@ -4,26 +4,22 @@ import android.app.Activity import androidx.lifecycle.viewModelScope import com.getcode.manager.AuthManager import com.getcode.manager.TopBarManager -import com.getcode.model.PrefsBool +import com.getcode.services.model.PrefsBool import com.getcode.network.repository.AppSettingsRepository import com.getcode.network.repository.BetaFlagsRepository import com.getcode.network.repository.BetaOptions import com.getcode.network.repository.FeatureRepository -import com.getcode.network.repository.PrefRepository import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel -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.asSharedFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject diff --git a/app/src/main/java/com/getcode/domain/CashLinkManager.kt b/app/src/main/java/com/getcode/domain/CashLinkManager.kt index d60161042..325b33df8 100644 --- a/app/src/main/java/com/getcode/domain/CashLinkManager.kt +++ b/app/src/main/java/com/getcode/domain/CashLinkManager.kt @@ -1,6 +1,5 @@ package com.getcode.domain -import com.getcode.ed25519.Ed25519 import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.model.KinAmount import com.getcode.network.repository.SendTransactionRepository diff --git a/app/src/main/java/com/getcode/inject/ApiModule.kt b/app/src/main/java/com/getcode/inject/ApiModule.kt index f503bba84..df920b4fc 100644 --- a/app/src/main/java/com/getcode/inject/ApiModule.kt +++ b/app/src/main/java/com/getcode/inject/ApiModule.kt @@ -2,57 +2,30 @@ package com.getcode.inject import android.content.Context import com.getcode.BuildConfig -import com.getcode.R -import com.getcode.analytics.AnalyticsService -import com.getcode.annotations.DevManagedChannel import com.getcode.api.KadoApi -import com.getcode.manager.MnemonicManager +import com.getcode.model.Currency import com.getcode.model.CurrencyCode -import com.getcode.model.PrefsString -import com.getcode.network.BalanceController -import com.getcode.network.PrivacyMigration -import com.getcode.network.api.CurrencyApi -import com.getcode.network.api.TransactionApiV2 -import com.getcode.network.client.AccountService -import com.getcode.network.client.Client -import com.getcode.network.client.TransactionReceiver -import com.getcode.network.core.NetworkOracle -import com.getcode.network.core.NetworkOracleImpl -import com.getcode.network.exchange.CodeExchange -import com.getcode.network.exchange.Exchange -import com.getcode.network.repository.AccountRepository -import com.getcode.network.repository.BalanceRepository -import com.getcode.network.repository.IdentityRepository -import com.getcode.network.repository.MessagingRepository import com.getcode.network.repository.PrefRepository -import com.getcode.network.repository.TransactionRepository -import com.getcode.network.service.ChatServiceV1 +import com.getcode.services.R +import com.getcode.services.db.CurrencyProvider +import com.getcode.services.model.PrefsString import com.getcode.util.AccountAuthenticator import com.getcode.util.locale.LocaleHelper -import com.getcode.utils.network.NetworkConnectivityListener -import com.getcode.network.service.ChatServiceV2 -import com.getcode.network.service.CurrencyService -import com.getcode.network.service.DeviceService -import com.getcode.util.CurrencyUtils import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.CurrencyUtils import com.mixpanel.android.mpmetrics.MixpanelAPI import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import io.grpc.ManagedChannel -import io.grpc.android.AndroidChannelBuilder import io.reactivex.rxjava3.core.Scheduler import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.schedulers.Schedulers import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor -import org.kin.sdk.base.network.api.agora.OkHttpChannelBuilderForcedTls12 import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory -import retrofit2.create -import java.util.concurrent.TimeUnit import javax.inject.Named import javax.inject.Singleton @@ -60,11 +33,6 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object ApiModule { - @Provides - fun provideNetworkOracle(): NetworkOracle { - return NetworkOracleImpl() - } - @Singleton @Provides fun provideCompositeDisposable(): CompositeDisposable { @@ -74,35 +42,6 @@ object ApiModule { @Provides fun provideScheduler(): Scheduler = Schedulers.io() - @Singleton - @Provides - fun provideManagedChannel(@ApplicationContext context: Context): ManagedChannel { - val TLS_PORT = 443 - val PROD_URL = "api.codeinfra.net" - - return AndroidChannelBuilder - .usingBuilder(OkHttpChannelBuilderForcedTls12.forAddress(PROD_URL, TLS_PORT)) - .context(context) - .userAgent("Code/Android/${BuildConfig.VERSION_NAME}") - .keepAliveTime(4, TimeUnit.MINUTES) - .build() - } - - @Singleton - @Provides - @DevManagedChannel - fun provideDevManagedChannel(@ApplicationContext context: Context): ManagedChannel { - val TLS_PORT = 443 - val DEV_URL = "api.codeinfra.dev" - - return AndroidChannelBuilder - .usingBuilder(OkHttpChannelBuilderForcedTls12.forAddress(DEV_URL, TLS_PORT)) - .context(context) - .userAgent("Code/Android/${BuildConfig.VERSION_NAME}") - .keepAliveTime(4, TimeUnit.MINUTES) - .build() - } - @Singleton @Provides fun provideAccountAuthenticator( @@ -150,123 +89,29 @@ object ApiModule { @Singleton @Provides - fun provideBalanceRepository( - ): BalanceRepository { - return BalanceRepository() - } - - @Singleton - @Provides - fun provideBalanceController( - exchange: Exchange, - balanceRepository: BalanceRepository, - transactionRepository: TransactionRepository, - accountRepository: AccountRepository, - privacyMigration: PrivacyMigration, - transactionReceiver: TransactionReceiver, - networkObserver: NetworkConnectivityListener, - resources: ResourceHelper, + fun providesCurrencyProvider( + prefRepository: PrefRepository, currencyUtils: CurrencyUtils, - ): BalanceController { - return BalanceController( - exchange = exchange, - balanceRepository = balanceRepository, - transactionRepository = transactionRepository, - accountRepository = accountRepository, - privacyMigration = privacyMigration, - transactionReceiver = transactionReceiver, - networkObserver = networkObserver, - getCurrencyFromCode = { - it?.name?.let(currencyUtils::getCurrency) - }, - suffix = { currency -> - if (currency?.code == CurrencyCode.KIN.name) { - "" - } else { - resources.getString(R.string.core_ofKin) - } - } - ) - } - - @Singleton - @Provides - fun providesExchange( - currencyService: CurrencyService, locale: LocaleHelper, - currencyUtils: CurrencyUtils, - prefRepository: PrefRepository, - ): Exchange = CodeExchange( - currencyService = currencyService, - prefs = prefRepository, - preferredCurrency = { + resources: ResourceHelper, + ): CurrencyProvider = object : CurrencyProvider { + override suspend fun preferredCurrency(): Currency? { val preferredCurrencyCode = prefRepository.get( PrefsString.KEY_LOCAL_CURRENCY, "" ).takeIf { it.isNotEmpty() } - val preferredCurrency = preferredCurrencyCode?.let { currencyUtils.getCurrency(it) } - preferredCurrency ?: locale.getDefaultCurrency() - }, - defaultCurrency = { locale.getDefaultCurrency() } - ) - - @Singleton - @Provides - fun provideClient( - identityRepository: IdentityRepository, - transactionRepository: TransactionRepository, - messagingRepository: MessagingRepository, - accountRepository: AccountRepository, - accountService: AccountService, - balanceController: BalanceController, - analytics: AnalyticsService, - prefRepository: PrefRepository, - transactionReceiver: TransactionReceiver, - exchange: Exchange, - networkObserver: NetworkConnectivityListener, - chatServiceV1: ChatServiceV1, - chatServiceV2: ChatServiceV2, - deviceService: DeviceService, - mnemonicManager: MnemonicManager, - ): Client { - return Client( - identityRepository, - transactionRepository, - messagingRepository, - balanceController, - accountRepository, - accountService, - analytics, - prefRepository, - exchange, - transactionReceiver, - networkObserver, - chatServiceV1, - chatServiceV2, - deviceService, - mnemonicManager - ) - } + return preferredCurrency ?: locale.getDefaultCurrency() + } - @Singleton - @Provides - fun providePrivacyMigration( - transactionRepository: TransactionRepository, - analytics: AnalyticsService, - ): PrivacyMigration { - return PrivacyMigration( - transactionRepository, - analytics - ) - } + override suspend fun defaultCurrency(): Currency? = locale.getDefaultCurrency() - @Singleton - @Provides - fun provideTransactionRepository( - @ApplicationContext context: Context, - transactionApi: TransactionApiV2, - ): TransactionRepository { - return TransactionRepository(transactionApi = transactionApi, context = context) + override fun suffix(currency: Currency?): String { + return if (currency?.code == CurrencyCode.KIN.name) { + "" + } else { + resources.getString(R.string.core_ofKin) + } + } } } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/inject/AppModule.kt b/app/src/main/java/com/getcode/inject/AppModule.kt index af897a06c..58552536f 100644 --- a/app/src/main/java/com/getcode/inject/AppModule.kt +++ b/app/src/main/java/com/getcode/inject/AppModule.kt @@ -14,15 +14,15 @@ import com.getcode.analytics.AnalyticsManager import com.getcode.analytics.AnalyticsService import com.getcode.util.AndroidLocale import com.getcode.util.AndroidPermissions -import com.getcode.util.AndroidResources -import com.getcode.util.Api25Vibrator -import com.getcode.util.Api26Vibrator -import com.getcode.util.Api31Vibrator -import com.getcode.util.CurrencyUtils +import com.getcode.util.vibration.Api25Vibrator +import com.getcode.util.vibration.Api26Vibrator +import com.getcode.util.vibration.Api31Vibrator import com.getcode.util.locale.LocaleHelper import com.getcode.util.permissions.PermissionChecker +import com.getcode.util.resources.AndroidResources import com.getcode.util.resources.ResourceHelper import com.getcode.util.vibration.Vibrator +import com.getcode.utils.CurrencyUtils import com.getcode.utils.network.Api24NetworkObserver import com.getcode.utils.network.Api29NetworkObserver import com.getcode.utils.network.NetworkConnectivityListener @@ -50,11 +50,6 @@ object AppModule { currencyUtils: CurrencyUtils, ): LocaleHelper = AndroidLocale(context, currencyUtils) - @Provides - fun providesAnalyticsService( - mixpanelAPI: MixpanelAPI - ): AnalyticsService = AnalyticsManager(mixpanelAPI) - @Provides fun providesWifiManager( @ApplicationContext context: Context, @@ -79,9 +74,16 @@ object AppModule { wifiManager: WifiManager ): NetworkConnectivityListener = when (Build.VERSION.SDK_INT) { in Build.VERSION_CODES.N .. Build.VERSION_CODES.P -> { - Api24NetworkObserver(wifiManager, connectivityManager, telephonyManager) + Api24NetworkObserver( + wifiManager, + connectivityManager, + telephonyManager + ) } - else -> Api29NetworkObserver(connectivityManager, telephonyManager) + else -> Api29NetworkObserver( + connectivityManager, + telephonyManager + ) } @Provides @@ -96,7 +98,7 @@ object AppModule { @Provides - fun providesCNotificationManager( + fun providesNotificationManager( @ApplicationContext context: Context ): NotificationManagerCompat = NotificationManagerCompat.from(context) diff --git a/app/src/main/java/com/getcode/inject/DataModule.kt b/app/src/main/java/com/getcode/inject/DataModule.kt deleted file mode 100644 index b7247b164..000000000 --- a/app/src/main/java/com/getcode/inject/DataModule.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.getcode.inject - -import com.getcode.mapper.ConversationMapper -import com.getcode.mapper.ConversationMessageWithContentMapper -import com.getcode.network.ConversationController -import com.getcode.network.ConversationStreamController -import com.getcode.network.ChatHistoryController -import com.getcode.network.TipController -import com.getcode.network.exchange.Exchange -import com.getcode.network.service.ChatServiceV2 -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -object DataModule { - - @Provides - @Singleton - fun providesConversationController( - historyController: ChatHistoryController, - chatServiceV2: ChatServiceV2, - exchange: Exchange, - conversationMapper: ConversationMapper, - messageWithContentMapper: ConversationMessageWithContentMapper, - tipController: TipController, - ): ConversationController = ConversationStreamController( - historyController = historyController, - exchange = exchange, - chatService = chatServiceV2, - conversationMapper = conversationMapper, - messageWithContentMapper = messageWithContentMapper, - tipController = tipController - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/manager/AuthManager.kt b/app/src/main/java/com/getcode/manager/AuthManager.kt index 4bceb47c9..bd1046505 100644 --- a/app/src/main/java/com/getcode/manager/AuthManager.kt +++ b/app/src/main/java/com/getcode/manager/AuthManager.kt @@ -6,31 +6,31 @@ import com.bugsnag.android.Bugsnag import com.getcode.BuildConfig import com.getcode.analytics.AnalyticsService import com.getcode.crypt.MnemonicPhrase -import com.getcode.db.Database +import com.getcode.db.CodeAppDatabase import com.getcode.db.InMemoryDao import com.getcode.model.AirdropType -import com.getcode.model.PrefsBool -import com.getcode.model.PrefsString +import com.getcode.services.model.PrefsBool +import com.getcode.services.model.PrefsString import com.getcode.model.description import com.getcode.network.BalanceController import com.getcode.network.NotificationCollectionHistoryController -import com.getcode.network.ChatHistoryController import com.getcode.network.exchange.Exchange import com.getcode.network.repository.BetaFlagsRepository import com.getcode.network.repository.IdentityRepository import com.getcode.network.repository.PhoneRepository import com.getcode.network.repository.PrefRepository import com.getcode.network.repository.PushRepository -import com.getcode.network.repository.encodeBase64 -import com.getcode.network.repository.getPublicKeyBase58 import com.getcode.network.repository.isMock +import com.getcode.services.db.Database +import com.getcode.services.utils.installationId +import com.getcode.services.utils.makeE164 +import com.getcode.services.utils.token import com.getcode.util.AccountUtils import com.getcode.utils.ErrorUtils import com.getcode.utils.TraceType -import com.getcode.utils.installationId -import com.getcode.utils.makeE164 +import com.getcode.utils.encodeBase64 +import com.getcode.utils.getPublicKeyBase58 import com.getcode.utils.trace -import com.getcode.utils.token import com.google.firebase.Firebase import com.google.firebase.installations.installations import com.google.firebase.messaging.FirebaseMessaging @@ -60,10 +60,9 @@ class AuthManager @Inject constructor( private val exchange: Exchange, private val balanceController: BalanceController, private val notificationCollectionHistory: NotificationCollectionHistoryController, - private val chatHistory: ChatHistoryController, private val inMemoryDao: InMemoryDao, private val analytics: AnalyticsService, - private val mnemonicManager: MnemonicManager, + private val mnemonicManager: com.getcode.services.manager.MnemonicManager, private val mixpanelAPI: MixpanelAPI ) : CoroutineScope by CoroutineScope(Dispatchers.IO) { private var softLoginDisabled: Boolean = false @@ -104,8 +103,9 @@ class AuthManager @Inject constructor( return Single.create { softLoginDisabled = true - if (!Database.isOpen()) { - Database.init(context, entropyB64) + if (!CodeAppDatabase.isOpen()) { + CodeAppDatabase.init(context, entropyB64) + Database.register(CodeAppDatabase.requireInstance()) } val originalSessionState = SessionManager.authState.value @@ -137,8 +137,9 @@ class AuthManager @Inject constructor( return Single.create { if (!isSoftLogin) softLoginDisabled = true - if (!Database.isOpen()) { - Database.init(context, entropyB64) + if (!CodeAppDatabase.isOpen()) { + CodeAppDatabase.init(context, entropyB64) + Database.register(CodeAppDatabase.requireInstance()) } val originalSessionState = SessionManager.authState.value @@ -291,7 +292,6 @@ class AuthManager @Inject constructor( launch { savePrefs(phone!!, user!!) } launch { exchange.fetchRatesIfNeeded() } launch { notificationCollectionHistory.fetch() } - launch { chatHistory.fetch() } } } @@ -312,7 +312,7 @@ class AuthManager @Inject constructor( Database.close() notificationCollectionHistory.reset() inMemoryDao.clear() - Database.delete(context) + launch { Database.delete(context) } if (!BuildConfig.DEBUG) Bugsnag.setUser(null, null, null) } diff --git a/app/src/main/java/com/getcode/mapper/AppSettingsMapper.kt b/app/src/main/java/com/getcode/mapper/AppSettingsMapper.kt index f33e2d44d..8657037e7 100644 --- a/app/src/main/java/com/getcode/mapper/AppSettingsMapper.kt +++ b/app/src/main/java/com/getcode/mapper/AppSettingsMapper.kt @@ -2,10 +2,11 @@ package com.getcode.mapper import androidx.biometric.BiometricManager import com.getcode.R -import com.getcode.model.APP_SETTINGS -import com.getcode.model.PrefsBool +import com.getcode.services.model.APP_SETTINGS +import com.getcode.services.model.PrefsBool import com.getcode.models.SettingItem import com.getcode.network.repository.AppSettings +import com.getcode.services.mapper.SuspendMapper import javax.inject.Inject class AppSettingsMapper @Inject constructor( diff --git a/app/src/main/java/com/getcode/models/SettingsItem.kt b/app/src/main/java/com/getcode/models/SettingsItem.kt index 7e563196d..b794316a4 100644 --- a/app/src/main/java/com/getcode/models/SettingsItem.kt +++ b/app/src/main/java/com/getcode/models/SettingsItem.kt @@ -1,6 +1,6 @@ package com.getcode.models -import com.getcode.model.AppSetting +import com.getcode.services.model.AppSetting data class SettingItem( val type: AppSetting, diff --git a/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt b/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt deleted file mode 100644 index d1b1fbaf5..000000000 --- a/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt +++ /dev/null @@ -1,216 +0,0 @@ -package com.getcode.navigation.screens - -import android.widget.Toast -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredWidth -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.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.draw.clip -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastForEachIndexed -import androidx.compose.ui.zIndex -import androidx.paging.compose.collectAsLazyPagingItems -import cafe.adriel.voyager.core.lifecycle.LifecycleEffect -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.hilt.getViewModel -import com.getcode.R -import com.getcode.model.ID -import com.getcode.model.TwitterUser -import com.getcode.model.chat.Reference -import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.theme.CodeTheme -import com.getcode.ui.components.chat.UserAvatar -import com.getcode.ui.utils.getActivityScopedViewModel -import com.getcode.util.formatDateRelatively -import com.getcode.view.main.balance.BalanceSheetViewModel -import com.getcode.view.main.chat.conversation.ConversationScreen -import com.getcode.view.main.chat.conversation.ConversationViewModel -import com.getcode.view.main.chat.create.byusername.ChatByUsernameScreen -import com.getcode.view.main.chat.list.ChatListScreen -import com.getcode.view.main.chat.list.ChatListViewModel -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -import kotlinx.parcelize.RawValue - -@Parcelize -data object ChatListModal: ChatGraph, ModalRoot { - - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - override val name: String - @Composable get() = stringResource(id = R.string.title_chat) - - @Composable - override fun Content() { - val navigator = LocalCodeNavigator.current - val viewModel = getActivityScopedViewModel() - ModalContainer( - closeButtonEnabled = { it is ChatListModal }, - ) { - ChatListScreen(viewModel) - } - - LifecycleEffect( - onStarted = { - val disposedScreen = navigator.lastItem - if (disposedScreen !is BalanceModal) { - viewModel.dispatchEvent(ChatListViewModel.Event.OnOpened) - } - } - ) - } -} - -@Parcelize -data object ChatByUsernameScreen: ChatGraph, ModalContent { - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - override val name: String - @Composable get() = stringResource(id = R.string.title_whatsTheirUsername) - - @Composable - override fun Content() { - ModalContainer( - backButtonEnabled = { it is ChatByUsernameScreen }, - ) { - ChatByUsernameScreen(getViewModel()) - } - } -} - -@Parcelize -data class ConversationScreen( - val user: @RawValue TwitterUser? = null, - val chatId: ID? = null, - val intentId: ID? = null -) : AppScreen(), ChatGraph, ModalContent { - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @Composable - override fun Content() { - val navigator = LocalCodeNavigator.current - val vm = getViewModel() - val state by vm.stateFlow.collectAsState() - - ModalContainer( - title = { - Row( - modifier = Modifier - .padding(start = CodeTheme.dimens.staticGrid.x6) - .align(Alignment.CenterStart), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy((-8).dp) - ) { - val imageModifier = Modifier - .padding(start = CodeTheme.dimens.grid.x7) - .size(CodeTheme.dimens.staticGrid.x6) - .clip(CircleShape) - - state.users.fastForEachIndexed { index, user -> - UserAvatar( - modifier = imageModifier - .zIndex((state.users.size - index).toFloat()), - data = if (user.isRevealed) { - user.imageUrl - } else { - user.memberId - } - ) - } - - if (state.users.isEmpty()) { - Spacer(modifier = Modifier.requiredWidth(CodeTheme.dimens.grid.x3)) - } - } - - Column { - val title = state.users.mapNotNull { it.username } - .joinToString() - .takeIf { it.isNotEmpty() } ?: "Anonymous Tipper" - Text( - text = title, - style = CodeTheme.typography.screenTitle - ) - state.lastSeen?.let { - Text( - text = "Last seen ${it.formatDateRelatively()}", - style = CodeTheme.typography.caption, - color = CodeTheme.colors.textSecondary, - ) - } - } - } - - }, - backButtonEnabled = { it is ConversationScreen }, - onBackClicked = { - navigator.popUntil { it is ChatListModal } - } - ) { - val messages = vm.messages.collectAsLazyPagingItems() - ConversationScreen(state, messages, vm::dispatchEvent) - } - - LaunchedEffect(vm) { - vm.eventFlow - .filterIsInstance() - .onEach { - navigator.push(EnterTipModal(isInChat = true)) - }.launchIn(this) - } - - val context = LocalContext.current - LaunchedEffect(vm) { - vm.eventFlow - .filterIsInstance() - .onEach { - if (it.show) { - Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show() - } - if (it.fatal) { - navigator.popAll() - } - }.launchIn(this) - } - - LaunchedEffect(user) { - if (user != null) { - vm.dispatchEvent( - ConversationViewModel.Event.OnTwitterUserChanged(user) - ) - } - } - - LaunchedEffect(chatId) { - if (chatId != null) { - vm.dispatchEvent( - ConversationViewModel.Event.OnChatIdChanged(chatId) - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/navigation/screens/LoginScreen.kt b/app/src/main/java/com/getcode/navigation/screens/LoginScreen.kt new file mode 100644 index 000000000..2c7adb842 --- /dev/null +++ b/app/src/main/java/com/getcode/navigation/screens/LoginScreen.kt @@ -0,0 +1,44 @@ +package com.getcode.navigation.screens + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.hilt.getViewModel +import com.getcode.LocalAnalytics +import com.getcode.R +import com.getcode.analytics.Action +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.view.login.LoginHome +import com.getcode.view.login.SeedDeepLink +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize + +@Parcelize +data class LoginScreen(val seed: String? = null) : LoginGraph { + @IgnoredOnParcel + override val key: ScreenKey = uniqueScreenKey + + override val name: String + @Composable get() = stringResource(id = R.string.action_logIn) + + @Composable + override fun Content() { + val navigator = LocalCodeNavigator.current + val analytics = LocalAnalytics.current + + if (seed != null) { + SeedDeepLink(getViewModel(), seed) + } else { + LoginHome( + createAccount = { + analytics.action(Action.CreateAccount) + navigator.push(LoginPhoneVerificationScreen(isNewAccount = true)) + }, + login = { + navigator.push(AccessKeyLoginScreen()) + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/navigation/screens/LoginScreens.kt b/app/src/main/java/com/getcode/navigation/screens/LoginScreens.kt index 1239ec72e..ef730ac11 100644 --- a/app/src/main/java/com/getcode/navigation/screens/LoginScreens.kt +++ b/app/src/main/java/com/getcode/navigation/screens/LoginScreens.kt @@ -7,55 +7,21 @@ import androidx.compose.ui.res.stringResource import cafe.adriel.voyager.core.screen.ScreenKey import cafe.adriel.voyager.core.screen.uniqueScreenKey import cafe.adriel.voyager.hilt.getViewModel -import com.getcode.LocalAnalytics import com.getcode.R -import com.getcode.analytics.Action -import com.getcode.analytics.AnalyticsManager import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.ui.utils.getStackScopedViewModel +import com.getcode.navigation.extensions.getStackScopedViewModel import com.getcode.view.login.AccessKey import com.getcode.view.login.AccessKeyViewModel import com.getcode.view.login.CameraPermission -import com.getcode.view.login.LoginHome import com.getcode.view.login.NotificationPermission import com.getcode.view.login.PhoneConfirm import com.getcode.view.login.PhoneVerify import com.getcode.view.login.PhoneVerifyViewModel -import com.getcode.view.login.SeedDeepLink import com.getcode.view.login.SeedInput import com.getcode.view.login.SeedInputViewModel import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize -@Parcelize -data class LoginScreen(val seed: String? = null) : LoginGraph { - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - override val name: String - @Composable get() = stringResource(id = R.string.action_logIn) - - @Composable - override fun Content() { - val navigator = LocalCodeNavigator.current - val analytics = LocalAnalytics.current - - if (seed != null) { - SeedDeepLink(getViewModel(), seed) - } else { - LoginHome( - createAccount = { - analytics.action(Action.CreateAccount) - navigator.push(LoginPhoneVerificationScreen(isNewAccount = true)) - }, - login = { - navigator.push(AccessKeyLoginScreen()) - } - ) - } - } -} - @Parcelize data class LoginPhoneVerificationScreen( val arguments: LoginArgs = LoginArgs() diff --git a/app/src/main/java/com/getcode/navigation/screens/MainScreens.kt b/app/src/main/java/com/getcode/navigation/screens/MainScreens.kt index cc11ddc3d..ae93e6c67 100644 --- a/app/src/main/java/com/getcode/navigation/screens/MainScreens.kt +++ b/app/src/main/java/com/getcode/navigation/screens/MainScreens.kt @@ -21,13 +21,12 @@ import cafe.adriel.voyager.navigator.currentOrThrow import com.getcode.LocalSession import com.getcode.R import com.getcode.model.ID -import com.getcode.model.chat.Reference import com.getcode.models.DeepLinkRequest import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.components.SheetTitleDefaults import com.getcode.ui.components.chat.utils.localized import com.getcode.ui.utils.RepeatOnLifecycle -import com.getcode.ui.utils.getActivityScopedViewModel +import com.getcode.navigation.extensions.getActivityScopedViewModel import com.getcode.utils.trace import com.getcode.view.download.ShareDownloadScreen import com.getcode.view.main.account.AccountHome @@ -39,10 +38,8 @@ import com.getcode.view.main.chat.NotificationCollectionViewModel import com.getcode.view.main.giveKin.GiveKinScreen import com.getcode.view.main.requestKin.RequestKinScreen import com.getcode.view.main.scanner.ScanScreen -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.parcelize.IgnoredOnParcel @@ -50,11 +47,11 @@ import kotlinx.parcelize.Parcelize @Parcelize data class ScanScreen( - val seed: String? = null, + override val seed: String? = null, val cashLink: String? = null, @IgnoredOnParcel val request: DeepLinkRequest? = null, -) : AppScreen(), MainGraph { +) : AppScreen(), MainScreen, MainGraph { @IgnoredOnParcel override val key: ScreenKey = uniqueScreenKey @@ -279,7 +276,6 @@ data class NotificationCollectionScreen(val collectionId: ID) : MainGraph, Modal override fun Content() { val vm = getViewModel() val state by vm.stateFlow.collectAsState() - val navigator = LocalCodeNavigator.current ModalContainer( titleString = { state.title.localized }, @@ -289,16 +285,6 @@ data class NotificationCollectionScreen(val collectionId: ID) : MainGraph, Modal ChatScreen(state = state, messages = messages, dispatch = vm::dispatchEvent) } - LaunchedEffect(vm) { - vm.eventFlow - .filterIsInstance() - .map { it.reference } - .filterIsInstance() - .map { it.id } - .onEach { navigator.push(ConversationScreen(intentId = it)) } - .launchIn(this) - } - LaunchedEffect(collectionId) { vm.dispatchEvent(NotificationCollectionViewModel.Event.OnChatIdChanged(collectionId)) } diff --git a/app/src/main/java/com/getcode/navigation/screens/ModalContainerMessage.kt b/app/src/main/java/com/getcode/navigation/screens/ModalContainerMessage.kt index 3ab21a9fb..0e4745813 100644 --- a/app/src/main/java/com/getcode/navigation/screens/ModalContainerMessage.kt +++ b/app/src/main/java/com/getcode/navigation/screens/ModalContainerMessage.kt @@ -3,27 +3,23 @@ package com.getcode.navigation.screens import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import cafe.adriel.voyager.core.screen.Screen -import com.getcode.manager.ModalManager -import com.getcode.theme.BrandLight +import com.getcode.navigation.modal.ModalHeightMetric +import com.getcode.services.manager.ModalManager import com.getcode.theme.CodeTheme -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton import com.getcode.ui.utils.addIf fun buildMessageContent( @@ -55,7 +51,7 @@ private data class ModalContainerMessage( message.icon?.let { imageResId -> Box( modifier = Modifier - .background(BrandLight, CircleShape), + .background(CodeTheme.colors.brandLight, CircleShape), contentAlignment = Alignment.Center ) { Image( diff --git a/app/src/main/java/com/getcode/navigation/screens/ModalScreens.kt b/app/src/main/java/com/getcode/navigation/screens/ModalScreens.kt index 540d55041..29412d1c2 100644 --- a/app/src/main/java/com/getcode/navigation/screens/ModalScreens.kt +++ b/app/src/main/java/com/getcode/navigation/screens/ModalScreens.kt @@ -14,13 +14,12 @@ import cafe.adriel.voyager.navigator.currentOrThrow import com.getcode.LocalSession import com.getcode.R import com.getcode.analytics.Action -import com.getcode.analytics.AnalyticsManager import com.getcode.analytics.AnalyticsScreenWatcher import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.SheetTitleDefaults -import com.getcode.ui.utils.getActivityScopedViewModel -import com.getcode.ui.utils.getStackScopedViewModel +import com.getcode.navigation.extensions.getActivityScopedViewModel +import com.getcode.navigation.extensions.getStackScopedViewModel import com.getcode.view.login.PhoneConfirm import com.getcode.view.login.PhoneVerify import com.getcode.view.login.PhoneVerifyViewModel @@ -44,7 +43,7 @@ import com.getcode.view.main.getKin.KadoWebScreen import com.getcode.view.main.tip.ConnectAccountScreen import com.getcode.view.main.tip.EnterTipScreen import com.getcode.view.main.tip.IdentityConnectionReason -import com.getcode.view.main.tip.TipConnectViewModel +import com.getcode.view.main.tip.ConnectAccountViewModel import com.kevinnzou.web.rememberWebViewNavigator import com.kevinnzou.web.rememberWebViewState import kotlinx.parcelize.IgnoredOnParcel @@ -458,15 +457,16 @@ data class EnterTipModal(val isInChat: Boolean = false) : MainGraph, ModalRoot { @Parcelize data class ConnectAccount( - private val reason: IdentityConnectionReason = IdentityConnectionReason.TipCard, + val reason: IdentityConnectionReason = IdentityConnectionReason.TipCard, ) : MainGraph, ModalContent { + @IgnoredOnParcel override val key: ScreenKey = uniqueScreenKey @Composable override fun Content() { val navigator = LocalCodeNavigator.current - val viewModel = getViewModel() + val viewModel = getViewModel() when (reason) { IdentityConnectionReason.TipCard -> { ModalContainer( @@ -494,13 +494,17 @@ data class ConnectAccount( ConnectAccountScreen(viewModel) } } + + IdentityConnectionReason.Login -> { + ConnectAccountScreen(viewModel) + } } LaunchedEffect(viewModel, reason) { - viewModel.dispatchEvent(TipConnectViewModel.Event.OnReasonChanged(reason)) + viewModel.dispatchEvent(ConnectAccountViewModel.Event.OnReasonChanged(reason)) } - if (reason == IdentityConnectionReason.TipCard) { + if (reason == IdentityConnectionReason.TipCard || reason == IdentityConnectionReason.Login) { AnalyticsScreenWatcher(action = Action.OpenConnectAccount) } } diff --git a/app/src/main/java/com/getcode/navigation/screens/Modals.kt b/app/src/main/java/com/getcode/navigation/screens/Modals.kt index beb2cfea3..12b2be7ec 100644 --- a/app/src/main/java/com/getcode/navigation/screens/Modals.kt +++ b/app/src/main/java/com/getcode/navigation/screens/Modals.kt @@ -24,7 +24,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalSoftwareKeyboardController import cafe.adriel.voyager.core.screen.Screen import com.getcode.LocalBetaFlags -import com.getcode.MainRoot import com.getcode.TopLevelViewModel import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.LocalCodeNavigator @@ -33,14 +32,11 @@ import com.getcode.ui.components.SheetTitle import com.getcode.ui.components.SheetTitleDefaults import com.getcode.ui.components.SheetTitleText import com.getcode.ui.components.keyboardAsState -import com.getcode.ui.utils.getActivityScopedViewModel +import com.getcode.navigation.extensions.getActivityScopedViewModel +import com.getcode.navigation.modal.ModalHeightMetric import kotlinx.coroutines.delay import kotlinx.coroutines.launch -sealed interface ModalHeightMetric { - data class Weight(val weight: Float) : ModalHeightMetric - data object WrapContent : ModalHeightMetric -} @OptIn(ExperimentalFoundationApi::class) @Composable @@ -121,7 +117,7 @@ internal fun NamedScreen.ModalContainer( modifier = Modifier .windowInsetsPadding(WindowInsets.navigationBars) ) { - val tlvm = MainRoot.getActivityScopedViewModel() + val tlvm = getActivityScopedViewModel() val state by tlvm.state.collectAsState() CompositionLocalProvider( LocalOverscrollConfiguration provides null, @@ -131,7 +127,4 @@ internal fun NamedScreen.ModalContainer( } } } -} - -internal interface ModalContent -internal sealed interface ModalRoot : ModalContent \ No newline at end of file +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/navigation/screens/PhoneCountrySelection.kt b/app/src/main/java/com/getcode/navigation/screens/PhoneCountrySelection.kt index 18c25c0af..a8a7e0de9 100644 --- a/app/src/main/java/com/getcode/navigation/screens/PhoneCountrySelection.kt +++ b/app/src/main/java/com/getcode/navigation/screens/PhoneCountrySelection.kt @@ -81,7 +81,7 @@ private fun PhoneCountrySelection( ) } Divider( - color = White05, + color = CodeTheme.colors.dividerVariant, modifier = Modifier .fillMaxWidth() .height(1.dp) diff --git a/app/src/main/java/com/getcode/navigation/screens/Screens.kt b/app/src/main/java/com/getcode/navigation/screens/Screens.kt index b29ba60a7..3a704229c 100644 --- a/app/src/main/java/com/getcode/navigation/screens/Screens.kt +++ b/app/src/main/java/com/getcode/navigation/screens/Screens.kt @@ -5,20 +5,6 @@ import cafe.adriel.voyager.core.screen.Screen import kotlinx.coroutines.flow.MutableStateFlow import timber.log.Timber -sealed interface NamedScreen { - - val name: String? - @Composable get() = null - - val hasName: Boolean - @Composable get() = !name.isNullOrEmpty() -} - -abstract class AppScreen: Screen { - var result = MutableStateFlow(null) - - fun onResult(obj: T) { - Timber.d("onResult=$obj") - result.value = obj - } +interface MainScreen { + val seed: String? } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/navigation/transitions/CrossfadeTransition.kt b/app/src/main/java/com/getcode/navigation/transitions/CrossfadeTransition.kt deleted file mode 100644 index 862d2dd41..000000000 --- a/app/src/main/java/com/getcode/navigation/transitions/CrossfadeTransition.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.getcode.navigation.transitions - -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import cafe.adriel.voyager.navigator.Navigator -import cafe.adriel.voyager.transitions.ScreenTransition -import cafe.adriel.voyager.transitions.ScreenTransitionContent - -@Composable -private fun CrossfadeTransition( - navigator: Navigator, - modifier: Modifier = Modifier, - content: ScreenTransitionContent = { it.Content() } -) { - ScreenTransition( - navigator = navigator, - modifier = modifier, - content = content, - transition = { fadeIn() togetherWith fadeOut() } - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/notifications/CodePushMessagingService.kt b/app/src/main/java/com/getcode/notifications/CodePushMessagingService.kt index f9cd54b18..da1fad6d3 100644 --- a/app/src/main/java/com/getcode/notifications/CodePushMessagingService.kt +++ b/app/src/main/java/com/getcode/notifications/CodePushMessagingService.kt @@ -18,18 +18,17 @@ import com.getcode.model.notifications.NotificationType import com.getcode.model.notifications.parse import com.getcode.network.BalanceController import com.getcode.network.NotificationCollectionHistoryController -import com.getcode.network.ChatHistoryController import com.getcode.network.TipController import com.getcode.network.repository.AccountRepository import com.getcode.network.repository.PushRepository import com.getcode.network.repository.TransactionRepository +import com.getcode.services.utils.installationId import com.getcode.ui.components.chat.utils.localizedText -import com.getcode.util.CurrencyUtils import com.getcode.util.resources.ResourceHelper import com.getcode.util.resources.ResourceType +import com.getcode.utils.CurrencyUtils import com.getcode.utils.ErrorUtils import com.getcode.utils.TraceType -import com.getcode.utils.installationId import com.getcode.utils.trace import com.getcode.view.MainActivity import com.google.firebase.Firebase @@ -76,9 +75,6 @@ class CodePushMessagingService : FirebaseMessagingService(), @Inject lateinit var notificationHistory: NotificationCollectionHistoryController - @Inject - lateinit var chatHistory: ChatHistoryController - @Inject lateinit var tipController: TipController @@ -106,14 +102,16 @@ class CodePushMessagingService : FirebaseMessagingService(), val (type, titleKey, messageContent) = notification if (type.isNotifiable()) { val title = titleKey.localizedStringByKey(resources) ?: titleKey - val body = messageContent.localizedText(title, resources, currencyUtils) + val body = messageContent.localizedText( + resources = resources, + currencyUtils = currencyUtils + ) notify(type, title, body) } when (type) { NotificationType.ChatMessage -> { launch { notificationHistory.fetch() } - launch { chatHistory.fetch() } launch { balanceController.fetchBalanceSuspend() } } diff --git a/app/src/main/java/com/getcode/ui/components/AuthCheck.kt b/app/src/main/java/com/getcode/ui/components/AuthCheck.kt index 25a6002e5..1224add2b 100644 --- a/app/src/main/java/com/getcode/ui/components/AuthCheck.kt +++ b/app/src/main/java/com/getcode/ui/components/AuthCheck.kt @@ -10,15 +10,16 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import cafe.adriel.voyager.core.screen.Screen +import com.getcode.AppHomeScreen import com.getcode.LocalDeeplinks import com.getcode.R import com.getcode.manager.BottomBarManager import com.getcode.manager.SessionManager import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.screens.AccessKeyLoginScreen -import com.getcode.navigation.screens.ScanScreen import com.getcode.navigation.screens.LoginGraph import com.getcode.navigation.screens.LoginScreen +import com.getcode.navigation.screens.MainScreen import com.getcode.util.DeeplinkHandler import com.getcode.util.DeeplinkResult import com.getcode.ui.utils.getActivity @@ -138,7 +139,7 @@ fun AuthCheck( if (authenticated) { if (!deeplinkRouted) { trace("Navigating to home") - onNavigate(listOf(ScanScreen())) + onNavigate(listOf(AppHomeScreen())) } } else { tipsEngine?.invalidateAllTips() @@ -160,7 +161,7 @@ private fun Flow.mapSeedToHome(): Flow = trace("mapping entropy to home screen") // send the user to home screen val entropy = (screens.first() as? LoginScreen)?.seed - val updatedData = data.copy(stack = listOf(ScanScreen(seed = entropy))) + val updatedData = data.copy(stack = listOf(AppHomeScreen(seed = entropy))) updatedData to auth } else { data to auth @@ -175,7 +176,7 @@ private fun Flow.showLogoutConfirmationIfNeeded( onCancel: () -> Unit ): Flow = onEach { (type, screens) -> if (type is DeeplinkHandler.Type.Login) { - val entropy = (screens.first() as? ScanScreen)?.seed + val entropy = (screens.first() as? MainScreen)?.seed if (entropy != null) { trace("showing logout confirm") showLogoutMessage( diff --git a/app/src/main/java/com/getcode/ui/components/FullScreenProgressSpinner.kt b/app/src/main/java/com/getcode/ui/components/FullScreenProgressSpinner.kt index a761a1e3d..f87400070 100644 --- a/app/src/main/java/com/getcode/ui/components/FullScreenProgressSpinner.kt +++ b/app/src/main/java/com/getcode/ui/components/FullScreenProgressSpinner.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.getcode.theme.CodeTheme +import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.utils.swallowClicks @Composable diff --git a/app/src/main/java/com/getcode/ui/components/ModalContainer.kt b/app/src/main/java/com/getcode/ui/components/ModalContainer.kt index 562451626..8a0bc8270 100644 --- a/app/src/main/java/com/getcode/ui/components/ModalContainer.kt +++ b/app/src/main/java/com/getcode/ui/components/ModalContainer.kt @@ -11,7 +11,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import cafe.adriel.voyager.core.stack.StackEvent import com.getcode.CodeAppState -import com.getcode.manager.ModalManager +import com.getcode.services.manager.ModalManager import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.screens.buildMessageContent import kotlinx.coroutines.delay diff --git a/app/src/main/java/com/getcode/ui/components/OtpBox.kt b/app/src/main/java/com/getcode/ui/components/OtpBox.kt index 02a0624a4..7ed0ba21d 100644 --- a/app/src/main/java/com/getcode/ui/components/OtpBox.kt +++ b/app/src/main/java/com/getcode/ui/components/OtpBox.kt @@ -10,7 +10,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme import com.getcode.theme.WindowSizeClass import com.getcode.ui.utils.rememberedClickable @@ -37,9 +36,9 @@ fun OtpBox( .rememberedClickable(onClick = onClick) .border( border = if (isHighlighted) - BorderStroke(CodeTheme.dimens.thickBorder, color = BrandLight.copy(alpha = 0.7f)) + BorderStroke(CodeTheme.dimens.thickBorder, color = CodeTheme.colors.brandLight.copy(alpha = 0.7f)) else - BorderStroke(CodeTheme.dimens.border, color = BrandLight.copy(alpha = 0.3f)), + BorderStroke(CodeTheme.dimens.border, color = CodeTheme.colors.brandLight.copy(alpha = 0.3f)), shape = CodeTheme.shapes.small ) .background(Color.White.copy(alpha = 0.1f)), diff --git a/app/src/main/java/com/getcode/ui/components/TextSection.kt b/app/src/main/java/com/getcode/ui/components/TextSection.kt index 663ac619c..ef1f82f03 100644 --- a/app/src/main/java/com/getcode/ui/components/TextSection.kt +++ b/app/src/main/java/com/getcode/ui/components/TextSection.kt @@ -2,11 +2,8 @@ package com.getcode.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp import com.getcode.theme.CodeTheme @Composable diff --git a/app/src/main/java/com/getcode/ui/components/TitleBar.kt b/app/src/main/java/com/getcode/ui/components/TitleBar.kt deleted file mode 100644 index 37f0d3eca..000000000 --- a/app/src/main/java/com/getcode/ui/components/TitleBar.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.getcode.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.material.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.ArrowBack -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.getcode.theme.Brand -import com.getcode.theme.CodeTheme -import com.getcode.theme.topBarHeight -import com.getcode.ui.utils.unboundedClickable - - -@Composable -fun TitleBar( - modifier: Modifier = Modifier, - title: String = "", - backButton: Boolean = false, - onBackIconClicked: () -> Unit = {} -) { - Surface( - color = Brand, - elevation = 0.dp - ) { - Box( - modifier = modifier - .statusBarsPadding() - .background(Brand) - .fillMaxWidth() - .height(topBarHeight), - ) { - if (backButton) { - Icon( - imageVector = Icons.Outlined.ArrowBack, - contentDescription = "", - tint = Color.White, - modifier = Modifier - .align(Alignment.CenterStart) - .padding(start = CodeTheme.dimens.inset) - .wrapContentWidth() - .size(24.dp) - .unboundedClickable { onBackIconClicked() } - ) - } - Text( - text = title, - color = Color.White, - style = CodeTheme.typography.screenTitle, - modifier = Modifier.align(Alignment.Center) - ) - } - } -} - -@Preview -@Composable -fun Preview_TitleBar( - -) { - CodeTheme { - TitleBar(backButton = true, title = "Hey") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/chat/AnnouncementMessage.kt b/app/src/main/java/com/getcode/ui/components/chat/AnnouncementMessage.kt deleted file mode 100644 index c42b86ab4..000000000 --- a/app/src/main/java/com/getcode/ui/components/chat/AnnouncementMessage.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.getcode.ui.components.chat - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import com.getcode.theme.BrandDark -import com.getcode.theme.CodeTheme - -@Composable -fun AnnouncementMessage( - modifier: Modifier = Modifier, - text: String, -) { - Box(modifier = modifier, contentAlignment = Alignment.Center) { - Column( - modifier = Modifier - .background( - color = BrandDark, - shape = MessageNodeDefaults.DefaultShape - ) - .padding(CodeTheme.dimens.grid.x2), - verticalArrangement = Arrangement.Center - ) { - Text( - text = text, - style = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.W500) - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/chat/AnonymousAvatar.kt b/app/src/main/java/com/getcode/ui/components/chat/AnonymousAvatar.kt deleted file mode 100644 index 7e5794610..000000000 --- a/app/src/main/java/com/getcode/ui/components/chat/AnonymousAvatar.kt +++ /dev/null @@ -1,189 +0,0 @@ -package com.getcode.ui.components.chat - -import android.graphics.Bitmap -import android.graphics.Paint -import android.graphics.Path -import android.graphics.RectF -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.getcode.network.repository.sha512 -import com.getcode.theme.CodeTheme -import com.getcode.ui.utils.UUIDPreviewParameterProvider -import com.getcode.ui.utils.deriveTargetColor -import com.getcode.ui.utils.toAGColor -import com.getcode.utils.bytes -import java.util.UUID -import kotlin.experimental.and -import kotlin.math.roundToInt - -enum class AnonymousRender { - EightBit, Gradient -} - -@Composable -fun AnonymousAvatar( - memberId: UUID, - modifier: Modifier = Modifier, - type: AnonymousRender = AnonymousRender.EightBit -) { - AnonymousAvatar(modifier = modifier, data = memberId.bytes, type = type) -} - -@Composable -fun AnonymousAvatar( - data: List, - modifier: Modifier = Modifier, - type: AnonymousRender = AnonymousRender.EightBit -) { - Box( - modifier = modifier - .background(Color(0xFFE6F0FA), CircleShape) - .aspectRatio(1f) - .clip(CircleShape) - .fillMaxSize() - .drawWithCache { - when (type) { - AnonymousRender.EightBit -> { - val avatar = if (size.isEmpty().not()) { - generateAvatar(data, size) - } else { - null - } - - onDrawWithContent { - if (avatar != null) { - drawImage(avatar) - } else { - drawRect(Color.Transparent) - } - } - } - AnonymousRender.Gradient -> { - val hash = runCatching { - data.toByteArray().sha512() - }.getOrNull() - - val gradient = if (hash != null) { - val sourceColor = rgbFromHash(hash.copyOfRange(0, 3)) - val derivedColor = deriveTargetColor(sourceColor, 0.3f, 0.5f) - - Brush.verticalGradient( - colors = listOf(sourceColor, derivedColor) - ) - } else { - null - } - - onDrawWithContent { - if (gradient != null) { - drawRect(brush = gradient) - } else { - drawRect(Color.Transparent) - } - } - } - } - } - ) -} - -@Preview -@Composable -fun Preview_Avatars() { - CodeTheme { - val provider = UUIDPreviewParameterProvider(40) - LazyVerticalGrid(columns = GridCells.Fixed(8)) { - items(provider.values.toList()) { - Box(modifier = Modifier.padding(8.dp)) { - AnonymousAvatar(modifier = Modifier.fillMaxSize(), memberId = it) - } - } - } - } -} - -private fun generateAvatar(data: List, size: Size): ImageBitmap? { - val hash = runCatching { data.toByteArray().sha512() }.getOrNull() ?: return null - val foregroundColor = rgbFromHash(hash.copyOfRange(0, 3)) - - val bitmap = Bitmap.createBitmap( - size.width.roundToInt(), - size.height.roundToInt(), - Bitmap.Config.ARGB_8888 - ) - val canvas = android.graphics.Canvas(bitmap) - val paint = Paint() - - val length = 10 - val rCount = length - val cCount = length / 2 - val cellSize = size.width / length.toFloat() - val inset = cellSize * 0.6f - - val bounds = RectF(0f, 0f, size.width, size.height) - val fullPath = Path().apply { addOval(bounds, Path.Direction.CCW) } - val maskPath = Path().apply { addOval(bounds.apply { inset(inset, inset) }, Path.Direction.CW) } - - val delta = Path().apply { - op(fullPath, maskPath, Path.Op.DIFFERENCE) - } - - val paths = mutableListOf() - - for (r in 0 until rCount) { - for (c in 0 until cCount) { - val i = r * cCount + c - val isEven = (hash[i] and 1) == 0.toByte() - if (isEven) { - val leftPath = createPath(r, c, cellSize) - if (!delta.intersects(leftPath)) { - paths.add(leftPath) - } - - val rightPath = createPath(r, length - c - 1, cellSize) - if (!delta.intersects(rightPath)) { - paths.add(rightPath) - } - } - } - } - - paint.color = foregroundColor.toAGColor() - paths.forEach { canvas.drawPath(it, paint) } - - return bitmap.asImageBitmap() -} - -private fun createPath(row: Int, col: Int, size: Float): Path { - return Path().apply { - addRect(col * size, row * size, (col + 1) * size, (row + 1) * size, Path.Direction.CW) - } -} - -private fun Path.intersects(other: Path): Boolean { - val result = Path() - result.op(this, other, Path.Op.INTERSECT) - return !result.isEmpty -} - -private fun rgbFromHash(hash: ByteArray): Color { - return Color(hash[0].toInt() and 0xFF, hash[1].toInt() and 0xFF, hash[2].toInt() and 0xFF) -} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/chat/ChatNode.kt b/app/src/main/java/com/getcode/ui/components/chat/ChatNode.kt deleted file mode 100644 index fadfe6d84..000000000 --- a/app/src/main/java/com/getcode/ui/components/chat/ChatNode.kt +++ /dev/null @@ -1,195 +0,0 @@ -package com.getcode.ui.components.chat - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.foundation.text.appendInlineContent -import androidx.compose.material.Icon -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.VolumeOff -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 -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.PlaceholderVerticalAlign -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextOverflow -import com.getcode.R -import com.getcode.model.MessageStatus -import com.getcode.model.chat.Chat -import com.getcode.model.chat.MessageContent -import com.getcode.model.chat.Pointer -import com.getcode.model.chat.isConversation -import com.getcode.model.chat.self -import com.getcode.model.uuid -import com.getcode.theme.BrandLight -import com.getcode.theme.CodeTheme -import com.getcode.ui.components.Badge -import com.getcode.ui.components.chat.utils.localized -import com.getcode.ui.components.chat.utils.localizedText -import com.getcode.ui.utils.rememberedClickable -import com.getcode.util.DateUtils -import com.getcode.util.formatTimeRelatively - -object ChatNodeDefaults { - val UnreadIndicator: Color = Color(0xFF31BB00) -} - -@Composable -fun ChatNode( - modifier: Modifier = Modifier, - chat: Chat, - showAvatar: Boolean = false, - onClick: () -> Unit, -) { - Row( - modifier = modifier - .rememberedClickable { onClick() } - .padding( - vertical = CodeTheme.dimens.grid.x3, - horizontal = CodeTheme.dimens.inset - ), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), - verticalAlignment = Alignment.CenterVertically - ) { - if (showAvatar) { - val imageModifier = Modifier - .size(CodeTheme.dimens.staticGrid.x10) - .clip(CircleShape) - - UserAvatar(modifier = imageModifier, data = chat.imageData) - } - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy( - CodeTheme.dimens.grid.x1, - Alignment.CenterVertically - ), - ) { - val hasUnreadMessages by remember(chat.unreadCount) { - derivedStateOf { chat.unreadCount > 0 } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = chat.title.localized, - maxLines = 1, - style = CodeTheme.typography.textMedium - ) - chat.lastMessageMillis?.let { - val isToday = DateUtils.isToday(it) - Text( - text = if (isToday) { - it.formatTimeRelatively() - } else { - DateUtils.getDateRelatively(it) - }, - style = CodeTheme.typography.textSmall, - color = if (hasUnreadMessages) ChatNodeDefaults.UnreadIndicator else CodeTheme.colors.brandLight, - ) - } - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset), - ) { - val (preview, inlineContent) = chat.messagePreview - - Text( - modifier = Modifier.weight(1f), - text = preview, - inlineContent = inlineContent, - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.brandLight, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - if (chat.isMuted) { - Icon( - imageVector = Icons.AutoMirrored.Filled.VolumeOff, - contentDescription = "chat is muted", - tint = BrandLight - ) - } else { - Badge( - Modifier - .padding(end = CodeTheme.dimens.grid.x1), - count = chat.unreadCount, - color = ChatNodeDefaults.UnreadIndicator - ) - } - } - } - } -} - -private val Chat.messagePreview: Pair> - @Composable get() { - val contents = newestMessage?.contents ?: return AnnotatedString("No content") to emptyMap() - - var filtered: List = contents.filterIsInstance() - if (filtered.isEmpty()) { - filtered = contents - } - - val selfMember = self - val pointer = - selfMember?.pointers.orEmpty().find { it.messageId == newestMessage?.id?.uuid } - - // joinToString does expose a Composable scoped lambda - @Suppress("SimplifiableCallChain") - val messageBody = filtered.map { it.localizedText }.joinToString(" ") - - val textStyle = CodeTheme.typography.textMedium - return if (pointer != null && pointer !is Pointer.Unknown && isConversation) { - val string = buildAnnotatedString { - appendInlineContent("status", "status") - append(" ") - append(messageBody) - } - - string to mapOf( - "status" to InlineTextContent( - Placeholder( - textStyle.fontSize, - textStyle.fontSize, - PlaceholderVerticalAlign.TextCenter - ) - ) { - Image( - painter = painterResource( - id = when (pointer) { - is Pointer.Delivered -> R.drawable.ic_message_status_delivered - is Pointer.Read -> R.drawable.ic_message_status_read - is Pointer.Sent -> R.drawable.ic_message_status_sent - else -> -1 - } - ), - modifier = Modifier.fillMaxSize(), - contentDescription = "" - ) - } - ) - } else { - AnnotatedString(messageBody) to emptyMap() - } - } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/chat/MessageList.kt b/app/src/main/java/com/getcode/ui/components/chat/MessageList.kt deleted file mode 100644 index d4619e182..000000000 --- a/app/src/main/java/com/getcode/ui/components/chat/MessageList.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.getcode.ui.components.chat - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.paging.compose.LazyPagingItems -import androidx.paging.compose.itemContentType -import androidx.paging.compose.itemKey -import com.getcode.model.ID -import com.getcode.model.MessageStatus -import com.getcode.model.chat.ChatMessage -import com.getcode.model.chat.MessageContent -import com.getcode.model.chat.Reference -import com.getcode.theme.CodeTheme -import com.getcode.ui.components.chat.utils.ChatItem -import com.getcode.util.formatDateRelatively - -sealed interface MessageListEvent { - data class OpenMessageChat(val reference: Reference): MessageListEvent - data class AdvancePointer(val messageId: ID): MessageListEvent -} -@Composable -fun MessageList( - modifier: Modifier = Modifier, - listState: LazyListState = rememberLazyListState(), - verticalArrangement: Arrangement.Vertical = Arrangement.Top, - messages: LazyPagingItems, - dispatch: (MessageListEvent) -> Unit = { }, -) { - - LaunchedEffect(messages.itemSnapshotList, listState) { - snapshotFlow { listState.firstVisibleItemIndex } - .collect { index -> - val closetChatMessage = messages.itemSnapshotList.toList().getClosestChat(index) - if (closetChatMessage != null) { - val (id, isFromSelf, status) = closetChatMessage - if (!isFromSelf) { - dispatch(MessageListEvent.AdvancePointer(id)) - } - } - } - } - - LazyColumn( - modifier = modifier, - state = listState, - reverseLayout = true, - - contentPadding = PaddingValues( - horizontal = CodeTheme.dimens.inset, - vertical = CodeTheme.dimens.inset, - ), - verticalArrangement = verticalArrangement, - ) { - items( - count = messages.itemCount, - key = messages.itemKey { item -> item.key }, - contentType = messages.itemContentType { item -> - when (item) { - is ChatItem.Date -> "separators" - is ChatItem.Message -> "messages" - } - } - ) { index -> - when (val item = messages[index]) { - is ChatItem.Date -> DateBubble( - modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x2), - date = item.date - ) - is ChatItem.Message -> { - // reverse layout so +1 to get previous - val prev = runCatching { messages[index + 1] } - .map { it as? ChatItem.Message } - .map { it?.chatMessageId } - .getOrNull() - // reverse layout so -1 to get next - val next = runCatching { messages[index - 1] } - .map { it as? ChatItem.Message } - .map { it?.chatMessageId } - .getOrNull() - - MessageNode( - modifier = Modifier.fillMaxWidth(), - contents = item.message, - status = item.status, - date = item.date, - isPreviousSameMessage = prev == item.chatMessageId, - isNextSameMessage = next == item.chatMessageId, - ) - } - - else -> Unit - } - } - // add last separator - // this isn't handled by paging separators due to no `beforeItem` to reference against - // at end of list due to reverseLayout - if (messages.itemCount > 0) { - (messages[messages.itemCount - 1] as? ChatItem.Message)?.date?.let { date -> - item { - val dateString = remember(date) { - date.formatDateRelatively() - } - DateBubble( - modifier = Modifier.padding(bottom = CodeTheme.dimens.grid.x2), - date = dateString - ) - } - } - } - } -} - - -private fun List.getClosestChat(index: Int): Triple? { - if (index !in indices) return null - val item = this[index] - return when { - item is ChatItem.Message -> Triple(item.chatMessageId, item.isFromSelf, item.status) - index > 0 -> getClosestChat(index - 1) - else -> null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt b/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt deleted file mode 100644 index 397aadc61..000000000 --- a/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt +++ /dev/null @@ -1,179 +0,0 @@ -package com.getcode.ui.components.chat - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.BoxWithConstraintsScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.CornerBasedShape -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import com.getcode.model.MessageStatus -import com.getcode.model.chat.MessageContent -import com.getcode.model.chat.Reference -import com.getcode.theme.BrandDark -import com.getcode.theme.ChatOutgoing -import com.getcode.theme.CodeTheme -import com.getcode.ui.components.chat.utils.localizedText -import kotlinx.datetime.Instant - -object MessageNodeDefaults { - - val DefaultShape: CornerBasedShape - @Composable get() = CodeTheme.shapes.small - val PreviousSameShape: CornerBasedShape - @Composable get() = DefaultShape.copy(topStart = CornerSize(3.dp)) - - val NextSameShape: CornerBasedShape - @Composable get() = DefaultShape.copy(bottomStart = CornerSize(3.dp)) - - val MiddleSameShape: CornerBasedShape - @Composable get() = DefaultShape.copy( - topStart = CornerSize(3.dp), - bottomStart = CornerSize(3.dp) - ) -} - -class MessageNodeScope( - private val contents: MessageContent, - private val boxScope: BoxWithConstraintsScope -) { - fun Modifier.sizeableWidth() = - this.widthIn(max = boxScope.maxWidth * 0.85f) - - val color: Color - @Composable get() = if (contents is MessageContent.Exchange && !contents.verb.increasesBalance) { - CodeTheme.colors.secondary - } else { - CodeTheme.colors.brandDark - } - - val isAnnouncement: Boolean - @Composable get() = remember { - when (contents) { - is MessageContent.IdentityRevealed -> true - is MessageContent.ThankYou -> true - else -> false - } - } -} - -@Composable -private fun rememberMessageNodeScope( - contents: MessageContent, - boxScope: BoxWithConstraintsScope -): MessageNodeScope { - return remember(contents, boxScope) { - MessageNodeScope(contents, boxScope) - } -} - -@Composable -fun MessageNode( - modifier: Modifier = Modifier, - contents: MessageContent, - date: Instant, - status: MessageStatus, - isPreviousSameMessage: Boolean, - isNextSameMessage: Boolean, -) { - BoxWithConstraints( - modifier = modifier - .padding(vertical = CodeTheme.dimens.grid.x1) - ) { - val scope = rememberMessageNodeScope(contents = contents, boxScope = this) - - with(scope) { - when (contents) { - is MessageContent.Exchange -> { - MessagePayment( - modifier = Modifier - .align(if (contents.isFromSelf) Alignment.CenterEnd else Alignment.CenterStart) - .sizeableWidth() - .background( - color = color, - shape = when { - isAnnouncement -> MessageNodeDefaults.DefaultShape - isPreviousSameMessage && isNextSameMessage -> MessageNodeDefaults.MiddleSameShape - isPreviousSameMessage -> MessageNodeDefaults.PreviousSameShape - isNextSameMessage -> MessageNodeDefaults.NextSameShape - else -> MessageNodeDefaults.DefaultShape - } - ), - contents = contents, - status = status, - date = date, - ) - } - - is MessageContent.Localized -> { - MessageText( - modifier = Modifier.fillMaxWidth(), - content = contents.localizedText, - date = date, - status = status, - isFromSelf = contents.isFromSelf - ) - } - - is MessageContent.SodiumBox -> { - EncryptedContent( - modifier = Modifier - .align(if (status.isOutgoing()) Alignment.CenterEnd else Alignment.CenterStart) - .sizeableWidth() - .background( - color = color, - shape = when { - isAnnouncement -> MessageNodeDefaults.DefaultShape - isPreviousSameMessage && isNextSameMessage -> MessageNodeDefaults.MiddleSameShape - isPreviousSameMessage -> MessageNodeDefaults.PreviousSameShape - isNextSameMessage -> MessageNodeDefaults.NextSameShape - else -> MessageNodeDefaults.DefaultShape - } - ) - .padding(CodeTheme.dimens.grid.x2), - date = date - ) - } - - is MessageContent.Decrypted -> { - MessageText( - modifier = Modifier.fillMaxWidth(), - content = contents.data, - date = date, - status = status, - isFromSelf = contents.isFromSelf - ) - } - - is MessageContent.IdentityRevealed -> { - AnnouncementMessage( - modifier = Modifier.align(Alignment.Center), - text = contents.localizedText - ) - } - is MessageContent.RawText -> { - MessageText( - modifier = Modifier.fillMaxWidth(), - content = contents.value, - date = date, - status = status, - isFromSelf = contents.isFromSelf - ) - } - is MessageContent.ThankYou -> { - AnnouncementMessage( - modifier = Modifier.align(Alignment.Center), - text = contents.localizedText - ) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/chat/MessageTextContent.kt b/app/src/main/java/com/getcode/ui/components/chat/MessageTextContent.kt deleted file mode 100644 index a182b39bb..000000000 --- a/app/src/main/java/com/getcode/ui/components/chat/MessageTextContent.kt +++ /dev/null @@ -1,186 +0,0 @@ -package com.getcode.ui.components.chat - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.rememberTextMeasurer -import com.getcode.model.MessageStatus -import com.getcode.theme.CodeTheme -import com.getcode.util.formatDateRelatively -import kotlinx.datetime.Instant - -@Composable -fun MessageNodeScope.MessageText( - modifier: Modifier = Modifier, - content: String, - isFromSelf: Boolean, - date: Instant, - status: MessageStatus = MessageStatus.Unknown, -) { - val alignment = if (isFromSelf) Alignment.CenterEnd else Alignment.CenterStart - - BoxWithConstraints(modifier = modifier.fillMaxWidth(), contentAlignment = alignment) { - BoxWithConstraints( - modifier = Modifier - .sizeableWidth() - .background( - color = color, - shape = MessageNodeDefaults.DefaultShape - ) - .padding(CodeTheme.dimens.grid.x2) - ) contents@{ - val maxWidthPx = with (LocalDensity.current) { maxWidth.roundToPx() } - Column( - modifier = Modifier - .background(color) - // add top padding to accommodate ascents - .padding(top = CodeTheme.dimens.grid.x1), - ) { - MessageContent( - maxWidth = maxWidthPx, - message = content, date = date, status = status, isFromSelf = isFromSelf) - } - } - } -} - -@Composable -private fun rememberAlignmentRule( - contentTextStyle: TextStyle, - maxWidth: Int, - message: String, - date: Instant -): State { - val density = LocalDensity.current - val dateTextStyle = DateWithStatusDefaults.DateTextStyle - val iconSizePx = with (density) { DateWithStatusDefaults.IconWidth.roundToPx() } - val spacingPx = with (density) { DateWithStatusDefaults.Spacing.roundToPx() } - val contentPaddingPx = with (density) { CodeTheme.dimens.grid.x2.roundToPx() } - - return remember(maxWidth, message, date) { - mutableStateOf(null) - }.apply { - val textMeasurer = rememberTextMeasurer() - val dateStatusWidth = remember(message, date) { - val result = textMeasurer.measure( - text = date.formatDateRelatively(), - style = dateTextStyle, - maxLines = 1 - ) - result.size.width + spacingPx + iconSizePx - } - - val bufferSize by remember(dateStatusWidth) { - derivedStateOf { - dateStatusWidth + spacingPx + contentPaddingPx * 2 - } - } - - if (value == null) { - Text( - modifier = Modifier.drawWithContent { }, - text = message, - style = contentTextStyle, - onTextLayout = { textLayoutResult -> - val lastLineNum = textLayoutResult.lineCount - 1 - val lineStart = textLayoutResult.getLineStart(lastLineNum) - val lineEnd = - textLayoutResult.getLineEnd(lastLineNum, visibleEnd = true) - val lineContent = message.substring(lineStart, lineEnd) - - val lineContentWidth = - textMeasurer.measure(lineContent, contentTextStyle).size.width - - value = when { - lineContentWidth + bufferSize > maxWidth -> AlignmentRule.Column - textLayoutResult.lineCount == 1 -> AlignmentRule.SingleLineEnd - else -> AlignmentRule.ParagraphLastLine - } - } - ) - } - } -} - -@Composable -private fun MessageContent(maxWidth: Int, message: String, date: Instant, status: MessageStatus, isFromSelf: Boolean) { - val contentStyle = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.W500) - val alignmentRule by rememberAlignmentRule( - contentTextStyle = contentStyle, - maxWidth = maxWidth, - message = message, - date = date, - ) - - when (alignmentRule) { - AlignmentRule.Column -> { - Column(verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2)) { - Text( - text = message, - style = contentStyle, - ) - DateWithStatus( - modifier = Modifier - .align(Alignment.End), - date = date, - status = status, - isFromSelf = isFromSelf - ) - } - } - AlignmentRule.ParagraphLastLine -> { - Column(verticalArrangement = Arrangement.spacedBy(-(CodeTheme.dimens.grid.x2))) { - Text( - text = message, - style = contentStyle, - ) - DateWithStatus( - modifier = Modifier - .align(Alignment.End), - date = date, - status = status, - isFromSelf = isFromSelf - ) - } - } - AlignmentRule.SingleLineEnd -> { - Row(horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1)) { - Text( - text = message, - style = contentStyle, - ) - DateWithStatus( - modifier = Modifier - .padding(top = CodeTheme.dimens.grid.x1), - date = date, - status = status, - isFromSelf = isFromSelf - ) - } - } - else -> Unit - } -} - -sealed interface AlignmentRule { - data object ParagraphLastLine: AlignmentRule - data object Column: AlignmentRule - data object SingleLineEnd: AlignmentRule -} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/chat/utils/ChatItem.kt b/app/src/main/java/com/getcode/ui/components/chat/utils/ChatItem.kt deleted file mode 100644 index b3e6e00cf..000000000 --- a/app/src/main/java/com/getcode/ui/components/chat/utils/ChatItem.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.getcode.ui.components.chat.utils - -import com.getcode.model.ID -import com.getcode.model.MessageStatus -import com.getcode.model.chat.ChatMessage -import com.getcode.model.chat.MessageContent -import kotlinx.datetime.Instant -import java.util.UUID - -data class ChatMessageIndice( - val message: ChatMessage, - val messageContent: MessageContent, -) - -sealed class ChatItem(open val key: Any) { - data class Message( - val id: String = UUID.randomUUID().toString(), - val chatMessageId: ID, - val message: MessageContent, - val date: Instant, - val status: MessageStatus, - val isFromSelf: Boolean, - override val key: Any = id - ) : ChatItem(key) - - data class Date(val date: String) : ChatItem(date) -} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/chat/utils/ConversationItem.kt b/app/src/main/java/com/getcode/ui/components/chat/utils/ConversationItem.kt deleted file mode 100644 index 34e7df785..000000000 --- a/app/src/main/java/com/getcode/ui/components/chat/utils/ConversationItem.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.getcode.ui.components.chat.utils - -import com.getcode.model.ConversationMessage -import com.getcode.model.ConversationMessageContent -import com.getcode.model.chat.MessageContent - -data class ConversationMessageIndice( - val message: ConversationMessage, - val messageContent: MessageContent, -) \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/modals/Confirmations.kt b/app/src/main/java/com/getcode/ui/modals/Confirmations.kt index 120bed617..5c551d8a9 100644 --- a/app/src/main/java/com/getcode/ui/modals/Confirmations.kt +++ b/app/src/main/java/com/getcode/ui/modals/Confirmations.kt @@ -16,7 +16,6 @@ import androidx.compose.ui.Alignment.Companion.BottomCenter import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.zIndex import cafe.adriel.voyager.navigator.currentOrThrow import com.getcode.LocalSession import com.getcode.R @@ -43,7 +42,7 @@ fun ConfirmationModals( val showScrim by remember(billState) { derivedStateOf { val loginConfirmation = billState.loginConfirmation - val paymentConfirmation = billState.paymentConfirmation + val paymentConfirmation = billState.privatePaymentConfirmation val socialPaymentConfirmation = billState.socialUserPaymentConfirmation listOf(loginConfirmation, paymentConfirmation, socialPaymentConfirmation).any { @@ -68,7 +67,7 @@ fun ConfirmationModals( // Payment Confirmation container AnimatedContent( modifier = Modifier.align(BottomCenter), - targetState = sessionState.billState.paymentConfirmation?.payload, // payload is constant across state changes + targetState = sessionState.billState.privatePaymentConfirmation?.payload, // payload is constant across state changes transitionSpec = AnimationUtils.modalAnimationSpec(), label = "payment confirmation", ) { @@ -77,7 +76,7 @@ fun ConfirmationModals( contentAlignment = BottomCenter ) { PaymentConfirmation( - confirmation = sessionState.billState.paymentConfirmation, + confirmation = sessionState.billState.privatePaymentConfirmation, balance = sessionState.balance, onAddKin = { session.rejectPayment() diff --git a/app/src/main/java/com/getcode/ui/modals/LoginConfirmation.kt b/app/src/main/java/com/getcode/ui/modals/LoginConfirmation.kt index 6a32aedf4..000b7318e 100644 --- a/app/src/main/java/com/getcode/ui/modals/LoginConfirmation.kt +++ b/app/src/main/java/com/getcode/ui/modals/LoginConfirmation.kt @@ -15,8 +15,8 @@ import com.getcode.R import com.getcode.models.ConfirmationState import com.getcode.models.LoginConfirmation import com.getcode.theme.CodeTheme -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton import com.getcode.ui.components.Modal import com.getcode.ui.components.SlideToConfirm import com.getcode.ui.components.SlideToConfirmDefaults diff --git a/app/src/main/java/com/getcode/ui/modals/PaymentConfirmation.kt b/app/src/main/java/com/getcode/ui/modals/PaymentConfirmation.kt index 33277a57e..80be81329 100644 --- a/app/src/main/java/com/getcode/ui/modals/PaymentConfirmation.kt +++ b/app/src/main/java/com/getcode/ui/modals/PaymentConfirmation.kt @@ -25,30 +25,32 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.getcode.R -import com.getcode.model.CodePayload +import com.getcode.services.model.CodePayload import com.getcode.model.CurrencyCode import com.getcode.model.Fiat import com.getcode.model.Kin.Companion.fromFiat import com.getcode.model.KinAmount -import com.getcode.model.Kind +import com.getcode.services.model.Kind import com.getcode.model.Rate -import com.getcode.models.PaymentConfirmation +import com.getcode.model.fromFiatAmount +import com.getcode.models.PrivatePaymentConfirmation import com.getcode.models.ConfirmationState import com.getcode.theme.CodeTheme +import com.getcode.theme.DesignSystem import com.getcode.theme.bolded -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton import com.getcode.ui.components.Modal +import com.getcode.ui.components.PriceWithFlag import com.getcode.ui.components.SlideToConfirm import com.getcode.ui.components.SlideToConfirmDefaults -import com.getcode.view.main.scanner.components.PriceWithFlag import kotlinx.coroutines.delay @Composable internal fun PaymentConfirmation( modifier: Modifier = Modifier, balance: KinAmount?, - confirmation: PaymentConfirmation?, + confirmation: PrivatePaymentConfirmation?, onAddKin: () -> Unit = { }, onSend: () -> Unit, onCancel: () -> Unit, @@ -106,7 +108,7 @@ private val payload = CodePayload( ).map { it.toByte() } ) -private fun confirmationWithState(state: ConfirmationState) = PaymentConfirmation( +private fun confirmationWithState(state: ConfirmationState) = PrivatePaymentConfirmation( state = state, payload = payload, requestedAmount = KinAmount.fromFiatAmount( @@ -124,7 +126,7 @@ private fun confirmationWithState(state: ConfirmationState) = PaymentConfirmatio @Preview(showBackground = true) @Composable fun Preview_PaymentConfirmModal_Awaiting() { - CodeTheme { + DesignSystem { Box( modifier = Modifier .fillMaxSize() @@ -145,7 +147,7 @@ fun Preview_PaymentConfirmModal_Awaiting() { @Preview(showBackground = true) @Composable fun Preview_PaymentConfirmModal_Sending() { - CodeTheme { + DesignSystem { Box( modifier = Modifier .fillMaxSize() @@ -166,7 +168,7 @@ fun Preview_PaymentConfirmModal_Sending() { @Preview(showBackground = true) @Composable fun Preview_PaymentConfirmModal_Sent() { - CodeTheme { + DesignSystem { Box( modifier = Modifier .fillMaxSize() @@ -187,15 +189,15 @@ fun Preview_PaymentConfirmModal_Sent() { @Preview(showBackground = true) @Composable fun Preview_PaymentConfirmModal_Interactive() { - CodeTheme { + DesignSystem { Box( modifier = Modifier .fillMaxSize() .background(Color.White) ) { var confirmation by remember { - mutableStateOf( - PaymentConfirmation( + mutableStateOf( + PrivatePaymentConfirmation( state = ConfirmationState.AwaitingConfirmation, payload = payload, requestedAmount = KinAmount.fromFiatAmount( @@ -274,7 +276,6 @@ private fun PaymentConfirmationContent( } SlideToConfirm( isLoading = isSending, - trackColor = SlideToConfirmDefaults.BlueTrackColor, isSuccess = state is ConfirmationState.Sent, onConfirm = { onApproved() }, ) diff --git a/app/src/main/java/com/getcode/ui/modals/ReceivedKinConfirmation.kt b/app/src/main/java/com/getcode/ui/modals/ReceivedKinConfirmation.kt index f7dba4b29..9e93d7e9f 100644 --- a/app/src/main/java/com/getcode/ui/modals/ReceivedKinConfirmation.kt +++ b/app/src/main/java/com/getcode/ui/modals/ReceivedKinConfirmation.kt @@ -13,12 +13,12 @@ import com.getcode.models.Bill import com.getcode.theme.Brand import com.getcode.theme.CodeTheme import com.getcode.theme.White -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton import com.getcode.ui.components.Modal -import com.getcode.util.flagResId -import com.getcode.util.formatted -import com.getcode.view.main.giveKin.AmountArea +import com.getcode.utils.flagResId +import com.getcode.extensions.formatted +import com.getcode.ui.components.text.AmountArea @Composable internal fun ReceivedKinConfirmation( diff --git a/app/src/main/java/com/getcode/ui/theme/CodeTheme.kt b/app/src/main/java/com/getcode/ui/theme/CodeTheme.kt new file mode 100644 index 000000000..09a3a2094 --- /dev/null +++ b/app/src/main/java/com/getcode/ui/theme/CodeTheme.kt @@ -0,0 +1,9 @@ +package com.getcode.ui.theme + +import androidx.compose.runtime.Composable +import com.getcode.theme.DesignSystem + +@Composable +fun CodeTheme(content: @Composable () -> Unit) { + DesignSystem(content = content) +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/utils/LazyList.kt b/app/src/main/java/com/getcode/ui/utils/LazyList.kt deleted file mode 100644 index 5a8b1168a..000000000 --- a/app/src/main/java/com/getcode/ui/utils/LazyList.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.getcode.ui.utils - - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.foundation.lazy.LazyItemScope -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.Composable - - -fun LazyListScope.animatedItem( - key: Any? = null, - contentType: Any? = null, - visible: Boolean, - enter: EnterTransition? = null, - exit: ExitTransition? = null, - content: @Composable LazyItemScope.() -> Unit -) { - item(key = key, contentType = contentType) { - if (enter != null && exit != null) { - AnimatedVisibility( - visible = visible, - enter = enter, - exit = exit, - ) { - content() - } - } else { - AnimatedVisibility( - visible = visible, - ) { - content() - } - } - } -} - -fun LazyListState.isScrolledToTheEnd() = - layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1 - -fun LazyListState.isScrolledToTheBeginning() = - (layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0) == 0 \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/utils/PreviewParameters.kt b/app/src/main/java/com/getcode/ui/utils/PreviewParameters.kt deleted file mode 100644 index fb67f6106..000000000 --- a/app/src/main/java/com/getcode/ui/utils/PreviewParameters.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.getcode.ui.utils - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import java.util.UUID - -class UUIDPreviewParameterProvider(count: Int = 40) : PreviewParameterProvider { - override val values: Sequence = generateSequence { UUID.randomUUID() }.take(count) -} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/util/AccountUtils.kt b/app/src/main/java/com/getcode/util/AccountUtils.kt index 0d1c2b1d9..46f70aecb 100644 --- a/app/src/main/java/com/getcode/util/AccountUtils.kt +++ b/app/src/main/java/com/getcode/util/AccountUtils.kt @@ -86,7 +86,10 @@ object AccountUtils { val am: AccountManager = AccountManager.get(context) val account = am.getAccountsByType(ACCOUNT_TYPE).firstOrNull() if (account == null) { - trace("no associated account found", type = TraceType.Error) + trace( + "no associated account found", + type = TraceType.Error + ) cont.resume(null) return@suspendCancellableCoroutine } @@ -103,7 +106,11 @@ object AccountUtils { cont.resume(authToken.orEmpty() to account) } catch (e: AuthenticatorException) { - trace(message = "failed to read account", error = e, type = TraceType.Error) + trace( + message = "failed to read account", + error = e, + type = TraceType.Error + ) cont.resume(null) } }, handler diff --git a/app/src/main/java/com/getcode/util/AndroidLocale.kt b/app/src/main/java/com/getcode/util/AndroidLocale.kt index 74fe0f32e..357737ebd 100644 --- a/app/src/main/java/com/getcode/util/AndroidLocale.kt +++ b/app/src/main/java/com/getcode/util/AndroidLocale.kt @@ -1,16 +1,15 @@ package com.getcode.util import android.content.Context -import com.getcode.App import com.getcode.model.Currency import com.getcode.util.locale.LocaleHelper -import com.getcode.utils.LocaleUtils +import com.getcode.util.locale.LocaleUtils import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject class AndroidLocale @Inject constructor( @ApplicationContext private val context: Context, - private val currencyUtils: CurrencyUtils, + private val currencyUtils: com.getcode.utils.CurrencyUtils, ): LocaleHelper { override fun getDefaultCurrencyName(): String { return LocaleUtils.getDefaultCurrency(context) diff --git a/app/src/main/java/com/getcode/util/AuthenticatorService.kt b/app/src/main/java/com/getcode/util/AuthenticatorService.kt index 902394748..b53df149b 100644 --- a/app/src/main/java/com/getcode/util/AuthenticatorService.kt +++ b/app/src/main/java/com/getcode/util/AuthenticatorService.kt @@ -5,7 +5,6 @@ import android.app.Service import android.content.Intent import android.os.IBinder import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject @AndroidEntryPoint diff --git a/app/src/main/java/com/getcode/util/ChromeTabsUtils.kt b/app/src/main/java/com/getcode/util/ChromeTabsUtils.kt index 11e0b777e..4d68cee16 100644 --- a/app/src/main/java/com/getcode/util/ChromeTabsUtils.kt +++ b/app/src/main/java/com/getcode/util/ChromeTabsUtils.kt @@ -2,7 +2,6 @@ package com.getcode.util import android.content.ComponentName import android.content.Context -import android.graphics.BitmapFactory import android.net.Uri import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabsClient diff --git a/app/src/main/java/com/getcode/util/Context.kt b/app/src/main/java/com/getcode/util/Context.kt index 66068f231..84c66273e 100644 --- a/app/src/main/java/com/getcode/util/Context.kt +++ b/app/src/main/java/com/getcode/util/Context.kt @@ -2,18 +2,12 @@ package com.getcode.util import android.content.Context import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.graphics.ImageDecoder import android.net.Uri import android.os.Build -import android.provider.MediaStore import android.provider.MediaStore.Images.Media import androidx.core.content.ContextCompat import com.getcode.R -import java.io.FileNotFoundException -import java.io.IOException -import java.io.InputStream -import kotlin.math.floor fun Context.launchAppSettings() { @@ -40,6 +34,6 @@ fun Context.uriToBitmap(uri: Uri): Bitmap? { decoder.isMutableRequired = true } } else { - MediaStore.Images.Media.getBitmap(contentResolver, uri) + Media.getBitmap(contentResolver, uri) } } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/util/DeeplinkHandler.kt b/app/src/main/java/com/getcode/util/DeeplinkHandler.kt index f1a30b411..9b2e94eec 100644 --- a/app/src/main/java/com/getcode/util/DeeplinkHandler.kt +++ b/app/src/main/java/com/getcode/util/DeeplinkHandler.kt @@ -7,15 +7,15 @@ import android.os.Parcelable import androidx.core.net.toUri import cafe.adriel.voyager.core.screen.Screen import com.getcode.R -import com.getcode.model.PrefsBool +import com.getcode.services.model.PrefsBool import com.getcode.models.DeepLinkRequest import com.getcode.navigation.screens.ScanScreen import com.getcode.navigation.screens.LoginScreen import com.getcode.network.repository.BetaFlagsRepository -import com.getcode.network.repository.urlDecode +import com.getcode.services.utils.base64EncodedData import com.getcode.utils.TraceType -import com.getcode.utils.base64EncodedData import com.getcode.utils.trace +import com.getcode.utils.urlDecode import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import timber.log.Timber @@ -49,13 +49,15 @@ class DeeplinkHandler @Inject constructor( set(value) { intent.value = value field = value - trace("debounced intent data=${value?.data}", type = TraceType.Silent) + trace( + "debounced intent data=${value?.data}", + type = TraceType.Silent + ) } val intent = MutableStateFlow(debounceIntent) suspend fun handle(intent: Intent? = debounceIntent): DeeplinkResult? { - println(intent) var uri = when { intent?.data != null -> intent.data intent?.getStringExtra(Intent.EXTRA_TEXT) != null -> { diff --git a/app/src/main/java/com/getcode/util/IntentUtils.kt b/app/src/main/java/com/getcode/util/IntentUtils.kt index ada046265..e5b02111c 100644 --- a/app/src/main/java/com/getcode/util/IntentUtils.kt +++ b/app/src/main/java/com/getcode/util/IntentUtils.kt @@ -1,11 +1,10 @@ package com.getcode.util -import android.content.Context import android.content.Intent import android.net.Uri import android.provider.Settings import com.getcode.BuildConfig -import com.getcode.utils.makeE164 +import com.getcode.services.utils.makeE164 object IntentUtils { diff --git a/app/src/main/java/com/getcode/util/Linkify.kt b/app/src/main/java/com/getcode/util/Linkify.kt index 3eb42501f..525576eba 100644 --- a/app/src/main/java/com/getcode/util/Linkify.kt +++ b/app/src/main/java/com/getcode/util/Linkify.kt @@ -1,6 +1,6 @@ package com.getcode.util -import com.getcode.network.repository.urlEncode +import com.getcode.utils.urlEncode object Linkify { fun cashLink(entropy: String): String = "https://cash.getcode.com/c/#/e=${entropy}" diff --git a/app/src/main/java/com/getcode/util/PhoneUtils.kt b/app/src/main/java/com/getcode/util/PhoneUtils.kt index 43b3aa67b..f7e02de7b 100644 --- a/app/src/main/java/com/getcode/util/PhoneUtils.kt +++ b/app/src/main/java/com/getcode/util/PhoneUtils.kt @@ -2,13 +2,10 @@ package com.getcode.util import android.content.Context import android.telephony.PhoneNumberUtils -import androidx.compose.runtime.ProvidableCompositionLocal -import androidx.compose.runtime.staticCompositionLocalOf -import com.getcode.App +import com.getcode.utils.CurrencyUtils import dagger.hilt.android.qualifiers.ApplicationContext import io.michaelrocks.libphonenumber.android.NumberParseException import io.michaelrocks.libphonenumber.android.PhoneNumberUtil -import timber.log.Timber import java.util.* import javax.inject.Inject import javax.inject.Singleton diff --git a/app/src/main/java/com/getcode/view/MainActivity.kt b/app/src/main/java/com/getcode/view/MainActivity.kt index d35c87e2a..ef39fb145 100644 --- a/app/src/main/java/com/getcode/view/MainActivity.kt +++ b/app/src/main/java/com/getcode/view/MainActivity.kt @@ -1,22 +1,22 @@ package com.getcode.view +import android.app.Activity import android.content.Intent import android.os.Bundle +import android.os.Process.killProcess +import android.os.Process.myPid import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentActivity +import com.getcode.BuildConfig import com.getcode.CodeApp import com.getcode.LocalAnalytics -import com.getcode.LocalCurrencyUtils import com.getcode.LocalDeeplinks import com.getcode.LocalDownloadQrCode -import com.getcode.LocalExchange -import com.getcode.LocalNetworkObserver import com.getcode.LocalPhoneFormatter import com.getcode.LocalSession import com.getcode.R @@ -25,25 +25,34 @@ import com.getcode.analytics.AnalyticsService import com.getcode.network.TipController import com.getcode.network.client.Client import com.getcode.network.exchange.Exchange +import com.getcode.network.exchange.LocalExchange import com.getcode.ui.tips.DefinedTips -import com.getcode.ui.utils.handleUncaughtException -import com.getcode.util.CurrencyUtils import com.getcode.util.DeeplinkHandler import com.getcode.util.PhoneUtils -import com.getcode.util.rememberQrBitmapPainter +import com.getcode.libs.qr.rememberQrBitmapPainter +import com.getcode.util.resources.LocalResources +import com.getcode.util.resources.ResourceHelper import com.getcode.util.vibration.LocalVibrator import com.getcode.util.vibration.Vibrator +import com.getcode.utils.CurrencyUtils +import com.getcode.utils.LocalCurrencyUtils +import com.getcode.utils.network.LocalNetworkObserver import com.getcode.utils.network.NetworkConnectivityListener import com.getcode.utils.trace +import com.google.firebase.crashlytics.FirebaseCrashlytics import dagger.hilt.android.AndroidEntryPoint import dev.bmcreations.tipkit.engines.LocalTipsEngine import dev.bmcreations.tipkit.engines.TipsEngine import timber.log.Timber import javax.inject.Inject +import kotlin.system.exitProcess @AndroidEntryPoint class MainActivity : FragmentActivity() { + @Inject + lateinit var resources: ResourceHelper + @Inject lateinit var client: Client @@ -85,17 +94,15 @@ class MainActivity : FragmentActivity() { * Invoking the navigation controller here will cause the intent to be fired * again we want to debounce this once when the activity is started with an intent. */ - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - if (intent != null) { - val cachedIntent = deeplinkHandler.debounceIntent - if (cachedIntent != null && cachedIntent.data == intent.data) { - Timber.d("Debouncing Intent " + intent.data) - deeplinkHandler.debounceIntent = null - return - } - deeplinkHandler.debounceIntent = intent + val cachedIntent = deeplinkHandler.debounceIntent + if (cachedIntent != null && cachedIntent.data == intent.data) { + Timber.d("Debouncing Intent " + intent.data) + deeplinkHandler.debounceIntent = null + return } + deeplinkHandler.debounceIntent = intent } override fun onCreate(savedInstanceState: Bundle?) { @@ -112,7 +119,7 @@ class MainActivity : FragmentActivity() { BoxWithConstraints { trace("set content") - val downloadQr = rememberQrBitmapPainter( + val downloadQr = com.getcode.libs.qr.rememberQrBitmapPainter( content = stringResource( R.string.app_download_link, stringResource(id = R.string.app_download_link_qr_ref) @@ -131,7 +138,8 @@ class MainActivity : FragmentActivity() { LocalCurrencyUtils provides currencyUtils, LocalExchange provides exchange, LocalDownloadQrCode provides downloadQr, - LocalTipsEngine provides tipEngine + LocalTipsEngine provides tipEngine, + LocalResources provides resources ) { CodeApp(tipEngine) } @@ -156,3 +164,21 @@ class MainActivity : FragmentActivity() { } } +private fun Activity.handleUncaughtException() { + val crashedKey = "isCrashed" + if (intent.getBooleanExtra(crashedKey, false)) return + Thread.setDefaultUncaughtExceptionHandler { _, throwable -> + if (BuildConfig.DEBUG) throw throwable + + FirebaseCrashlytics.getInstance().recordException(throwable) + + val intent = Intent(this, MainActivity::class.java).apply { + putExtra(crashedKey, true) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) + finish() + killProcess(myPid()) + exitProcess(2) + } +} diff --git a/app/src/main/java/com/getcode/view/download/ShareDownloadScreen.kt b/app/src/main/java/com/getcode/view/download/ShareDownloadScreen.kt index 940efd402..987e429b8 100644 --- a/app/src/main/java/com/getcode/view/download/ShareDownloadScreen.kt +++ b/app/src/main/java/com/getcode/view/download/ShareDownloadScreen.kt @@ -31,10 +31,10 @@ import androidx.compose.ui.text.style.TextAlign import com.getcode.LocalDownloadQrCode import com.getcode.R import com.getcode.theme.CodeTheme -import com.getcode.ui.components.ButtonState +import com.getcode.ui.theme.ButtonState import com.getcode.ui.components.Cloudy -import com.getcode.ui.components.CodeButton -import com.getcode.ui.components.CodeCircularProgressIndicator +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.components.Row import com.getcode.ui.components.SelectionContainer import com.getcode.ui.components.rememberSelectionState diff --git a/app/src/main/java/com/getcode/view/login/AccessKey.kt b/app/src/main/java/com/getcode/view/login/AccessKey.kt index 203f70ecc..f6b85cd2f 100644 --- a/app/src/main/java/com/getcode/view/login/AccessKey.kt +++ b/app/src/main/java/com/getcode/view/login/AccessKey.kt @@ -30,7 +30,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.scale @@ -44,7 +43,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isSpecified import androidx.hilt.navigation.compose.hiltViewModel -import com.getcode.LocalTopBarPadding import com.getcode.R import com.getcode.manager.BottomBarManager import com.getcode.manager.TopBarManager @@ -52,19 +50,19 @@ import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.LoginArgs import com.getcode.theme.CodeTheme import com.getcode.theme.White -import com.getcode.ui.components.ButtonState +import com.getcode.ui.LocalTopBarPadding +import com.getcode.ui.theme.ButtonState import com.getcode.ui.components.Cloudy -import com.getcode.ui.components.CodeButton -import com.getcode.ui.components.PermissionResult +import com.getcode.ui.theme.CodeButton import com.getcode.ui.components.SelectionContainer -import com.getcode.ui.components.getPermissionLauncher -import com.getcode.ui.components.rememberPermissionChecker import com.getcode.ui.components.rememberSelectionState import com.getcode.ui.utils.addIf import com.getcode.ui.utils.measured import com.getcode.util.launchAppSettings +import com.getcode.util.permissions.PermissionResult +import com.getcode.util.permissions.getPermissionLauncher +import com.getcode.util.permissions.rememberPermissionHandler -@OptIn(ExperimentalComposeUiApi::class) @Preview @Composable fun AccessKey( @@ -98,7 +96,7 @@ fun AccessKey( } val launcher = getPermissionLauncher(Manifest.permission.WRITE_EXTERNAL_STORAGE, onPermissionResult) - val permissionChecker = rememberPermissionChecker() + val permissionChecker = rememberPermissionHandler() if (isExportSeedRequested && isStoragePermissionGranted) { viewModel.onSubmit(navigator, true) diff --git a/app/src/main/java/com/getcode/view/login/AccessKeyViewModel.kt b/app/src/main/java/com/getcode/view/login/AccessKeyViewModel.kt index fbe352146..fe45abcca 100644 --- a/app/src/main/java/com/getcode/view/login/AccessKeyViewModel.kt +++ b/app/src/main/java/com/getcode/view/login/AccessKeyViewModel.kt @@ -3,20 +3,21 @@ package com.getcode.view.login import android.Manifest import android.annotation.SuppressLint import android.os.Build +import com.getcode.AppHomeScreen import com.getcode.analytics.Action import com.getcode.analytics.ActionSource import com.getcode.analytics.AnalyticsService +import com.getcode.libs.qr.QRCodeGenerator import com.getcode.manager.AuthManager -import com.getcode.manager.MnemonicManager +import com.getcode.services.manager.MnemonicManager import com.getcode.media.MediaScanner import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.screens.CodeLoginPermission -import com.getcode.navigation.screens.ScanScreen import com.getcode.navigation.screens.LoginScreen import com.getcode.navigation.screens.PermissionRequestScreen -import com.getcode.network.repository.getPublicKeyBase58 import com.getcode.util.permissions.PermissionChecker import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.getPublicKeyBase58 import dagger.hilt.android.lifecycle.HiltViewModel import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Completable @@ -33,7 +34,8 @@ class AccessKeyViewModel @Inject constructor( private val mnemonicManager: MnemonicManager, resources: ResourceHelper, mediaScanner: MediaScanner, -) : BaseAccessKeyViewModel(resources, mnemonicManager, mediaScanner) { + qrCodeGenerator: QRCodeGenerator, +) : BaseAccessKeyViewModel(resources, mnemonicManager, mediaScanner, qrCodeGenerator) { @SuppressLint("CheckResult") fun onSubmit(navigator: CodeNavigator, isSaveImage: Boolean, isDeepLink: Boolean = false) { val entropyB64 = uiFlow.value.entropyB64 ?: return @@ -84,7 +86,7 @@ class AccessKeyViewModel @Inject constructor( } else { if (Build.VERSION.SDK_INT < 33) { analytics.action(Action.CompletedOnboarding) - navigator.replaceAll(ScanScreen()) + navigator.replaceAll(AppHomeScreen()) } else { val notificationsPermissionDenied = permissions.isDenied( Manifest.permission.POST_NOTIFICATIONS @@ -94,7 +96,7 @@ class AccessKeyViewModel @Inject constructor( navigator.push(PermissionRequestScreen(CodeLoginPermission.Notifications, true)) } else { analytics.action(Action.CompletedOnboarding) - navigator.replaceAll(ScanScreen()) + navigator.replaceAll(AppHomeScreen()) } } } diff --git a/app/src/main/java/com/getcode/view/login/BaseAccessKeyViewModel.kt b/app/src/main/java/com/getcode/view/login/BaseAccessKeyViewModel.kt index 5f3b36fef..7810af0ef 100644 --- a/app/src/main/java/com/getcode/view/login/BaseAccessKeyViewModel.kt +++ b/app/src/main/java/com/getcode/view/login/BaseAccessKeyViewModel.kt @@ -9,33 +9,29 @@ import androidx.core.graphics.applyCanvas import androidx.core.graphics.drawable.toBitmap import androidx.lifecycle.viewModelScope import com.getcode.R -import com.getcode.manager.MnemonicManager +import com.getcode.libs.qr.QRCodeGenerator +import com.getcode.services.manager.MnemonicManager import com.getcode.manager.SessionManager import com.getcode.manager.TopBarManager import com.getcode.media.MediaScanner -import com.getcode.network.repository.TransactionRepository import com.getcode.network.repository.DeniedReason import com.getcode.network.repository.ErrorSubmitIntent import com.getcode.network.repository.ErrorSubmitIntentException -import com.getcode.network.repository.decodeBase64 import com.getcode.theme.Alert import com.getcode.theme.Brand import com.getcode.theme.White import com.getcode.ui.utils.toAGColor -import com.getcode.util.generateQrCode import com.getcode.util.resources.ResourceHelper import com.getcode.util.save -import com.getcode.utils.ErrorUtils -import com.getcode.vendor.Base58 +import com.getcode.utils.decodeBase64 import com.getcode.view.BaseViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.launch +import org.kin.sdk.base.tools.Base58 import timber.log.Timber -import java.io.File -import java.io.FileOutputStream import java.text.DateFormat import java.text.SimpleDateFormat import java.util.Date @@ -58,6 +54,7 @@ abstract class BaseAccessKeyViewModel( private val resources: ResourceHelper, private val mnemonicManager: MnemonicManager, private val mediaScanner: MediaScanner, + private val qrCodeGenerator: QRCodeGenerator, ) : BaseViewModel(resources) { val uiFlow = MutableStateFlow(AccessKeyUiModel()) @@ -241,7 +238,7 @@ abstract class BaseAccessKeyViewModel( val base58 = Base58.encode(entropyB64.decodeBase64()) val url = "${resources.getString(R.string.root_url_app)}/login?data=$base58" - return generateQrCode(url, qrCodeSize) + return qrCodeGenerator.generate(url, qrCodeSize) } private fun drawText( diff --git a/app/src/main/java/com/getcode/view/login/CameraPermission.kt b/app/src/main/java/com/getcode/view/login/CameraPermission.kt index 8ec87ae2c..e181be948 100644 --- a/app/src/main/java/com/getcode/view/login/CameraPermission.kt +++ b/app/src/main/java/com/getcode/view/login/CameraPermission.kt @@ -9,17 +9,18 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.constraintlayout.compose.ConstraintLayout +import com.getcode.AppHomeScreen import com.getcode.LocalAnalytics import com.getcode.R import com.getcode.analytics.Action import com.getcode.navigation.screens.CodeLoginPermission import com.getcode.navigation.core.CodeNavigator -import com.getcode.navigation.screens.ScanScreen import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.PermissionRequestScreen import com.getcode.theme.CodeTheme -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.util.permissions.cameraPermissionCheck @Composable fun CameraPermission(navigator: CodeNavigator = LocalCodeNavigator.current, fromOnboarding: Boolean = false) { @@ -33,14 +34,15 @@ fun CameraPermission(navigator: CodeNavigator = LocalCodeNavigator.current, from if (fromOnboarding) { analytics.action(Action.CompletedOnboarding) } - navigator.replaceAll(ScanScreen()) + navigator.replaceAll(AppHomeScreen()) } else { navigator.push(PermissionRequestScreen(CodeLoginPermission.Notifications, fromOnboarding)) } } } - val notificationPermissionCheck = notificationPermissionCheck { onNotificationResult(it) } + val notificationPermissionCheck = + com.getcode.util.permissions.notificationPermissionCheck { onNotificationResult(it) } val onCameraResult: (Boolean) -> Unit = { isGranted -> if (isGranted) { diff --git a/app/src/main/java/com/getcode/view/login/LoginHome.kt b/app/src/main/java/com/getcode/view/login/LoginHome.kt index c9e21c3b4..8f25f4b29 100644 --- a/app/src/main/java/com/getcode/view/login/LoginHome.kt +++ b/app/src/main/java/com/getcode/view/login/LoginHome.kt @@ -23,18 +23,15 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle -import androidx.compose.ui.tooling.preview.Preview import androidx.constraintlayout.compose.ConstraintLayout import com.getcode.LocalAnalytics import com.getcode.R import com.getcode.theme.Brand import com.getcode.theme.CodeTheme -import com.getcode.util.ChromeTabsUtils -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton import com.getcode.ui.components.ImageWithBackground - - +import com.getcode.util.ChromeTabsUtils @Composable fun LoginHome( @@ -109,7 +106,7 @@ fun LoginHome( append(" ") append(stringResource(R.string.login_description_agreeToOur)) append(" ") - pushStringAnnotation(tag = "tos", annotation = "https://app.getcode.com/tos") + pushStringAnnotation(tag = "tos", annotation = stringResource(R.string.app_privacy_policy)) withStyle(style = SpanStyle(textDecoration = TextDecoration.Underline)) { append(stringResource(R.string.title_termsOfService)) } @@ -119,7 +116,7 @@ fun LoginHome( append(" ") pushStringAnnotation( tag = "policy", - annotation = "https://app.getcode.com/privacy-policy" + annotation = stringResource(R.string.app_privacy_policy) ) withStyle(style = SpanStyle(textDecoration = TextDecoration.Underline)) { append(stringResource(R.string.title_privacyPolicy)) @@ -159,15 +156,4 @@ fun LoginHome( analytics.onAppStarted() } -} - -@Preview -@Composable -private fun Preview_Login() { - CodeTheme { - LoginHome( - createAccount = { }, - login = {} - ) - } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/login/NotificationPermission.kt b/app/src/main/java/com/getcode/view/login/NotificationPermission.kt index 490dc20a9..1252c771b 100644 --- a/app/src/main/java/com/getcode/view/login/NotificationPermission.kt +++ b/app/src/main/java/com/getcode/view/login/NotificationPermission.kt @@ -9,15 +9,15 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.constraintlayout.compose.ConstraintLayout +import com.getcode.AppHomeScreen import com.getcode.LocalAnalytics import com.getcode.R import com.getcode.analytics.Action import com.getcode.navigation.core.CodeNavigator -import com.getcode.navigation.screens.ScanScreen import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton @Composable fun NotificationPermission(navigator: CodeNavigator = LocalCodeNavigator.current, fromOnboarding: Boolean = false) { @@ -27,10 +27,13 @@ fun NotificationPermission(navigator: CodeNavigator = LocalCodeNavigator.current if (fromOnboarding) { analytics.action(Action.CompletedOnboarding) } - navigator.replaceAll(ScanScreen()) + navigator.replaceAll(AppHomeScreen()) } } - val notificationPermissionCheck = notificationPermissionCheck(onResult = { onNotificationResult(it) }) + val notificationPermissionCheck = + com.getcode.util.permissions.notificationPermissionCheck(onResult = { + onNotificationResult(it) + }) SideEffect { notificationPermissionCheck(false) diff --git a/app/src/main/java/com/getcode/view/login/PhoneConfirm.kt b/app/src/main/java/com/getcode/view/login/PhoneConfirm.kt index eddb202c2..909cbeb23 100644 --- a/app/src/main/java/com/getcode/view/login/PhoneConfirm.kt +++ b/app/src/main/java/com/getcode/view/login/PhoneConfirm.kt @@ -42,13 +42,12 @@ import com.getcode.LocalPhoneFormatter import com.getcode.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.LoginArgs -import com.getcode.network.repository.replaceParam -import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton -import com.getcode.ui.components.CodeCircularProgressIndicator +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.components.OtpRow +import com.getcode.utils.replaceParam internal const val OTP_LENGTH = 6 @@ -144,7 +143,7 @@ fun PhoneConfirm( } else if (dataState.isResendingCode) { CodeCircularProgressIndicator( strokeWidth = CodeTheme.dimens.thickBorder, - color = BrandLight, + color = CodeTheme.colors.brandLight, modifier = Modifier .size(CodeTheme.dimens.grid.x4) .align(Alignment.CenterHorizontally), diff --git a/app/src/main/java/com/getcode/view/login/PhoneConfirmViewModel.kt b/app/src/main/java/com/getcode/view/login/PhoneConfirmViewModel.kt index a86e119f7..5177db8b4 100644 --- a/app/src/main/java/com/getcode/view/login/PhoneConfirmViewModel.kt +++ b/app/src/main/java/com/getcode/view/login/PhoneConfirmViewModel.kt @@ -3,26 +3,27 @@ package com.getcode.view.login import android.annotation.SuppressLint import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue -import com.codeinc.gen.phone.v1.PhoneVerificationService -import com.codeinc.gen.user.v1.IdentityService import com.getcode.R import com.getcode.analytics.Action import com.getcode.analytics.AnalyticsService import com.getcode.ed25519.Ed25519 -import com.getcode.manager.MnemonicManager +import com.getcode.services.manager.MnemonicManager import com.getcode.manager.SessionManager import com.getcode.manager.TopBarManager import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.screens.AccessKeyScreen import com.getcode.navigation.screens.ScanScreen import com.getcode.navigation.screens.PhoneNumberScreen +import com.getcode.network.repository.CheckVerificationResult import com.getcode.network.repository.IdentityRepository +import com.getcode.network.repository.LinkAccountResult +import com.getcode.network.repository.OtpVerificationResult import com.getcode.network.repository.PhoneRepository -import com.getcode.network.repository.encodeBase64 import com.getcode.util.OtpSmsBroadcastReceiver import com.getcode.util.PhoneUtils import com.getcode.util.resources.ResourceHelper import com.getcode.utils.ErrorUtils +import com.getcode.utils.encodeBase64 import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers @@ -66,7 +67,7 @@ class PhoneConfirmViewModel @Inject constructor( private val phoneRepository: PhoneRepository, private val phoneUtils: PhoneUtils, private val resources: ResourceHelper, - private val mnemonicManager: MnemonicManager, + private val mnemonicManager: com.getcode.services.manager.MnemonicManager, ) : BaseViewModel(resources) { companion object { @@ -194,12 +195,14 @@ class PhoneConfirmViewModel @Inject constructor( .doOnTerminate { setIsResending(false) } .doOnComplete { setIsResending(false) } .observeOn(AndroidSchedulers.mainThread()) - .map { res -> - when (res) { - PhoneVerificationService.SendVerificationCodeResponse.Result.OK -> null - else -> getGenericError() - }?.let { message -> TopBarManager.showMessage(message) } - res == PhoneVerificationService.SendVerificationCodeResponse.Result.OK + .map { result -> + when (result) { + is OtpVerificationResult.Error -> { + TopBarManager.showMessage(getGenericError()) + false + } + OtpVerificationResult.Success -> true + } } .subscribe({ startTimer() }, ErrorUtils::handleError) } @@ -217,17 +220,19 @@ class PhoneConfirmViewModel @Inject constructor( phoneRepository.checkVerificationCode(phoneNumber, otpInput) .map { res -> when (res) { - PhoneVerificationService.CheckVerificationCodeResponse.Result.OK -> null - PhoneVerificationService.CheckVerificationCodeResponse.Result.INVALID_CODE -> - getInvalidCodeError() - - PhoneVerificationService.CheckVerificationCodeResponse.Result.NO_VERIFICATION -> - getTimeoutError() + CheckVerificationResult.Error.InvalidCode -> { + TopBarManager.showMessage(getInvalidCodeError()) + } + CheckVerificationResult.Error.NoVerification -> { + TopBarManager.showMessage(getTimeoutError()) + } + is CheckVerificationResult.Error -> { + TopBarManager.showMessage(getGenericError()) + } + CheckVerificationResult.Success -> Unit + } - else -> - getGenericError() - }?.let { message -> TopBarManager.showMessage(message) } - res == PhoneVerificationService.CheckVerificationCodeResponse.Result.OK + res == CheckVerificationResult.Success }.firstOrError() .doOnSuccess { isSuccess -> if (isSuccess) onOtpValidated() else onOtpError() } } @@ -241,14 +246,11 @@ class PhoneConfirmViewModel @Inject constructor( return identityRepository.linkAccount(keyPair, phoneValue, code) .map { res -> when (res) { - IdentityService.LinkAccountResponse.Result.OK -> null - IdentityService.LinkAccountResponse.Result.INVALID_TOKEN -> - getInvalidCodeError() - - else -> - getGenericError() - }?.let { message -> TopBarManager.showMessage(message) } - res == IdentityService.LinkAccountResponse.Result.OK + LinkAccountResult.Error.InvalidCode -> TopBarManager.showMessage(getInvalidCodeError()) + is LinkAccountResult.Error -> getGenericError() + LinkAccountResult.Success -> Unit + } + res == LinkAccountResult.Success } } diff --git a/app/src/main/java/com/getcode/view/login/PhoneVerify.kt b/app/src/main/java/com/getcode/view/login/PhoneVerify.kt index 9c8e35cc3..63ca063d6 100644 --- a/app/src/main/java/com/getcode/view/login/PhoneVerify.kt +++ b/app/src/main/java/com/getcode/view/login/PhoneVerify.kt @@ -59,7 +59,6 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.getcode.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.LoginArgs -import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme import com.getcode.theme.White05 import com.getcode.theme.White50 @@ -67,8 +66,8 @@ import com.getcode.theme.extraSmall import com.getcode.util.PhoneUtils import com.getcode.ui.utils.getActivity import com.getcode.ui.utils.rememberedClickable -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton import com.google.android.gms.auth.api.identity.GetPhoneNumberHintIntentRequest import com.google.android.gms.auth.api.identity.Identity import kotlinx.coroutines.delay @@ -200,7 +199,7 @@ private fun PhoneEntry( modifier = modifier .border( width = CodeTheme.dimens.border, - color = BrandLight, + color = CodeTheme.colors.brandLight, shape = CodeTheme.shapes.extraSmall ) .background(White05) @@ -247,7 +246,7 @@ private fun PhoneEntry( } Spacer( modifier = Modifier - .background(BrandLight) + .background(CodeTheme.colors.brandLight) .width(1.dp) .fillMaxHeight() ) diff --git a/app/src/main/java/com/getcode/view/login/PhoneVerifyViewModel.kt b/app/src/main/java/com/getcode/view/login/PhoneVerifyViewModel.kt index 0e5efdb78..5c6b005b0 100644 --- a/app/src/main/java/com/getcode/view/login/PhoneVerifyViewModel.kt +++ b/app/src/main/java/com/getcode/view/login/PhoneVerifyViewModel.kt @@ -4,22 +4,20 @@ import android.annotation.SuppressLint import android.app.Activity import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue -import com.codeinc.gen.phone.v1.PhoneVerificationService import com.getcode.R import com.getcode.analytics.Action -import com.getcode.analytics.AnalyticsManager import com.getcode.analytics.AnalyticsService import com.getcode.manager.TopBarManager import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.screens.LoginPhoneConfirmationScreen import com.getcode.navigation.screens.PhoneConfirmationScreen -import com.getcode.network.repository.ErrorSubmitIntent +import com.getcode.network.repository.OtpVerificationResult import com.getcode.network.repository.PhoneRepository import com.getcode.util.PhoneUtils import com.getcode.util.resources.ResourceHelper import com.getcode.utils.ErrorUtils -import com.getcode.utils.makeE164 -import com.getcode.view.* +import com.getcode.services.utils.makeE164 +import com.getcode.view.BaseViewModel import com.google.android.gms.auth.api.phone.SmsRetriever import dagger.hilt.android.lifecycle.HiltViewModel import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers @@ -178,30 +176,25 @@ class PhoneVerifyViewModel @Inject constructor( .doOnComplete { setIsLoading(false) } .map { res -> val message = when (res) { - PhoneVerificationService.SendVerificationCodeResponse.Result.OK -> null + OtpVerificationResult.Error.InvalidPhoneNumber, + OtpVerificationResult.Error.UnsupportedPhoneType -> getUnsupportedPhoneError() - PhoneVerificationService.SendVerificationCodeResponse.Result.INVALID_PHONE_NUMBER, - PhoneVerificationService.SendVerificationCodeResponse.Result.UNSUPPORTED_PHONE_TYPE -> { - getUnsupportedPhoneError() - } - PhoneVerificationService.SendVerificationCodeResponse.Result.UNSUPPORTED_COUNTRY -> { - getUnsupportedCountryError() - } - PhoneVerificationService.SendVerificationCodeResponse.Result.UNRECOGNIZED, - PhoneVerificationService.SendVerificationCodeResponse.Result.UNSUPPORTED_DEVICE -> { - getUnsupportedDeviceError() - } - else -> getGenericError() + OtpVerificationResult.Error.UnsupportedCountry -> getUnsupportedCountryError() + OtpVerificationResult.Error.UnsupportedDevice -> getUnsupportedDeviceError() + + is OtpVerificationResult.Error -> getGenericError() + + OtpVerificationResult.Success -> null } if (message != null) { TopBarManager.showMessage(message) } - val success = res == PhoneVerificationService.SendVerificationCodeResponse.Result.OK + val success = res == OtpVerificationResult.Success if (!success) { - ErrorUtils.handleError(PhoneVerifyException(reason = res.name)) + ErrorUtils.handleError(PhoneVerifyException(reason = res::class.java.simpleName)) } success diff --git a/app/src/main/java/com/getcode/view/login/SeedDeepLink.kt b/app/src/main/java/com/getcode/view/login/SeedDeepLink.kt index 878793717..6391e180f 100644 --- a/app/src/main/java/com/getcode/view/login/SeedDeepLink.kt +++ b/app/src/main/java/com/getcode/view/login/SeedDeepLink.kt @@ -16,18 +16,18 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.getcode.AppHomeScreen import com.getcode.R import com.getcode.manager.SessionManager import com.getcode.manager.TopBarManager import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.CodeLoginPermission -import com.getcode.navigation.screens.ScanScreen import com.getcode.navigation.screens.LoginScreen import com.getcode.navigation.screens.PermissionRequestScreen -import com.getcode.network.repository.decodeBase64 -import com.getcode.network.repository.encodeBase64 -import com.getcode.vendor.Base58 -import com.getcode.ui.components.CodeCircularProgressIndicator +import com.getcode.ui.theme.CodeCircularProgressIndicator +import com.getcode.utils.decodeBase64 +import com.getcode.utils.encodeBase64 +import org.kin.sdk.base.tools.Base58 import timber.log.Timber @Preview @@ -42,7 +42,7 @@ fun SeedDeepLink( val authState by SessionManager.authState.collectAsState() fun navigateMain() { - navigator.replaceAll(ScanScreen()) + navigator.replaceAll(AppHomeScreen()) } fun navigateLogin() = navigator.replace(LoginScreen()) @@ -53,7 +53,8 @@ fun SeedDeepLink( navigator.push(PermissionRequestScreen(CodeLoginPermission.Notifications)) } } - val notificationPermissionCheck = notificationPermissionCheck { onNotificationResult(it) } + val notificationPermissionCheck = + com.getcode.util.permissions.notificationPermissionCheck { onNotificationResult(it) } fun onError() { TopBarManager.showMessage( diff --git a/app/src/main/java/com/getcode/view/login/SeedInput.kt b/app/src/main/java/com/getcode/view/login/SeedInput.kt index 41cf16e44..0202ea448 100644 --- a/app/src/main/java/com/getcode/view/login/SeedInput.kt +++ b/app/src/main/java/com/getcode/view/login/SeedInput.kt @@ -1,6 +1,5 @@ package com.getcode.view.login -import android.Manifest import android.annotation.SuppressLint import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* @@ -12,7 +11,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -30,7 +28,8 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.LoginArgs -import com.getcode.ui.components.* +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton @SuppressLint("InlinedApi") @Preview @@ -44,7 +43,8 @@ fun SeedInput( val focusManager = LocalFocusManager.current val focusRequester = FocusRequester() - val notificationPermissionCheck = notificationPermissionCheck(isShowError = false) { } + val notificationPermissionCheck = + com.getcode.util.permissions.notificationPermissionCheck(isShowError = false) { } Column( modifier = Modifier diff --git a/app/src/main/java/com/getcode/view/login/SeedInputViewModel.kt b/app/src/main/java/com/getcode/view/login/SeedInputViewModel.kt index 2ae7da73b..1b270db48 100644 --- a/app/src/main/java/com/getcode/view/login/SeedInputViewModel.kt +++ b/app/src/main/java/com/getcode/view/login/SeedInputViewModel.kt @@ -3,6 +3,7 @@ package com.getcode.view.login import android.annotation.SuppressLint import android.app.Activity import androidx.lifecycle.viewModelScope +import com.getcode.AppHomeScreen import dagger.hilt.android.lifecycle.HiltViewModel import com.getcode.R import com.getcode.analytics.AnalyticsService @@ -10,10 +11,9 @@ import com.getcode.crypt.MnemonicPhrase import com.getcode.manager.AccountManager import com.getcode.manager.AuthManager import com.getcode.manager.BottomBarManager -import com.getcode.manager.MnemonicManager +import com.getcode.services.manager.MnemonicManager import com.getcode.manager.TopBarManager import com.getcode.navigation.core.CodeNavigator -import com.getcode.navigation.screens.ScanScreen import com.getcode.navigation.screens.LoginPhoneVerificationScreen import com.getcode.util.resources.ResourceHelper import com.getcode.utils.ErrorUtils @@ -116,7 +116,7 @@ class SeedInputViewModel @Inject constructor( .observeOn(AndroidSchedulers.mainThread()) .subscribe( { - navigator.replaceAll(ScanScreen()) + navigator.replaceAll(AppHomeScreen()) }, { if (it is AuthManager.AuthManagerException.TimelockUnlockedException) { TopBarManager.showMessage( diff --git a/app/src/main/java/com/getcode/view/main/account/AccountAccessKeyViewModel.kt b/app/src/main/java/com/getcode/view/main/account/AccountAccessKeyViewModel.kt index b3fd2d5ec..738973bdd 100644 --- a/app/src/main/java/com/getcode/view/main/account/AccountAccessKeyViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/account/AccountAccessKeyViewModel.kt @@ -2,9 +2,9 @@ package com.getcode.view.main.account import android.annotation.SuppressLint import androidx.lifecycle.viewModelScope +import com.getcode.libs.qr.QRCodeGenerator import com.getcode.media.MediaScanner -import com.getcode.manager.MnemonicManager -import com.getcode.navigation.core.CodeNavigator +import com.getcode.services.manager.MnemonicManager import com.getcode.util.resources.ResourceHelper import com.getcode.view.login.BaseAccessKeyViewModel import dagger.hilt.android.lifecycle.HiltViewModel @@ -23,7 +23,8 @@ class AccountAccessKeyViewModel @Inject constructor( resources: ResourceHelper, mnemonicManager: MnemonicManager, mediaScanner: MediaScanner, -) : BaseAccessKeyViewModel(resources, mnemonicManager, mediaScanner) { + qrCodeGenerator: QRCodeGenerator, +) : BaseAccessKeyViewModel(resources, mnemonicManager, mediaScanner, qrCodeGenerator) { @SuppressLint("CheckResult") fun onSubmit() { Completable.create { diff --git a/app/src/main/java/com/getcode/view/main/account/AccountDeposit.kt b/app/src/main/java/com/getcode/view/main/account/AccountDeposit.kt index 986cd597a..4b98b8d54 100644 --- a/app/src/main/java/com/getcode/view/main/account/AccountDeposit.kt +++ b/app/src/main/java/com/getcode/view/main/account/AccountDeposit.kt @@ -28,16 +28,15 @@ import androidx.compose.ui.text.style.TextAlign import com.getcode.R import com.getcode.manager.SessionManager import com.getcode.theme.Brand -import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme import com.getcode.theme.White import com.getcode.theme.White05 import com.getcode.theme.extraSmall import com.getcode.ui.utils.rememberedClickable -import com.getcode.vendor.Base58 -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton import com.getcode.ui.components.MiddleEllipsisText +import org.kin.sdk.base.tools.Base58 @Composable fun AccountDeposit() { @@ -70,7 +69,7 @@ fun AccountDeposit() { modifier = Modifier .padding(vertical = CodeTheme.dimens.grid.x3) .clip(CodeTheme.shapes.extraSmall) - .border(CodeTheme.dimens.border, BrandLight, CodeTheme.shapes.extraSmall) + .border(CodeTheme.dimens.border, CodeTheme.colors.brandLight, CodeTheme.shapes.extraSmall) .fillMaxWidth() .height(CodeTheme.dimens.grid.x10) .background(White05) diff --git a/app/src/main/java/com/getcode/view/main/account/AccountFaq.kt b/app/src/main/java/com/getcode/view/main/account/AccountFaq.kt index f1b9f012d..6983e9980 100644 --- a/app/src/main/java/com/getcode/view/main/account/AccountFaq.kt +++ b/app/src/main/java/com/getcode/view/main/account/AccountFaq.kt @@ -9,9 +9,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.getcode.theme.R as themeR import com.getcode.theme.CodeTheme diff --git a/app/src/main/java/com/getcode/view/main/account/AccountHome.kt b/app/src/main/java/com/getcode/view/main/account/AccountHome.kt index 70c69390d..7b6461be8 100644 --- a/app/src/main/java/com/getcode/view/main/account/AccountHome.kt +++ b/app/src/main/java/com/getcode/view/main/account/AccountHome.kt @@ -50,7 +50,7 @@ import com.getcode.navigation.screens.FaqScreen import com.getcode.navigation.screens.WithdrawalAmountScreen import com.getcode.theme.CodeTheme import com.getcode.theme.White10 -import com.getcode.ui.components.CodeScaffold +import com.getcode.ui.theme.CodeScaffold import com.getcode.ui.utils.getActivity import com.getcode.ui.utils.rememberedClickable import com.getcode.ui.utils.verticalScrollStateGradient @@ -215,7 +215,7 @@ fun ListItem(item: AccountMainItem, onClick: () -> Unit) { Divider( modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), - color = White10, + color = CodeTheme.colors.divider, thickness = 0.5.dp ) } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/account/AccountPhone.kt b/app/src/main/java/com/getcode/view/main/account/AccountPhone.kt index a20f03320..5e59fcbb8 100644 --- a/app/src/main/java/com/getcode/view/main/account/AccountPhone.kt +++ b/app/src/main/java/com/getcode/view/main/account/AccountPhone.kt @@ -22,12 +22,12 @@ import com.getcode.manager.BottomBarManager import com.getcode.manager.SessionManager import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.PhoneVerificationScreen -import com.getcode.network.repository.urlEncode import com.getcode.theme.Brand import com.getcode.theme.CodeTheme import com.getcode.theme.bolded -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.utils.urlEncode @Composable fun AccountPhone( diff --git a/app/src/main/java/com/getcode/view/main/account/AccountPhoneViewModel.kt b/app/src/main/java/com/getcode/view/main/account/AccountPhoneViewModel.kt index 958c8ca84..bd2a939c0 100644 --- a/app/src/main/java/com/getcode/view/main/account/AccountPhoneViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/account/AccountPhoneViewModel.kt @@ -1,12 +1,12 @@ package com.getcode.view.main.account -import com.codeinc.gen.user.v1.IdentityService import com.getcode.manager.SessionManager import com.getcode.network.repository.IdentityRepository import com.getcode.network.repository.PhoneRepository +import com.getcode.network.repository.UnlinkAccountResult import com.getcode.util.PhoneUtils import com.getcode.util.resources.ResourceHelper -import com.getcode.utils.makeE164 +import com.getcode.services.utils.makeE164 import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -45,9 +45,10 @@ class AccountPhoneViewModel @Inject constructor( ?.filter { it.isDigit() }?.makeE164() ?: return identityRepository.unlinkAccount(keyPair, phoneNumber).subscribe { result -> - if (result == IdentityService.UnlinkAccountResponse.Result.OK) + if (result is UnlinkAccountResult.Success) { phoneRepository.phoneLinked.value = false - uiFlow.value = AccountPhoneUiModel(false, null) + uiFlow.value = AccountPhoneUiModel(false, null) + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/account/AccountSheetViewModel.kt b/app/src/main/java/com/getcode/view/main/account/AccountSheetViewModel.kt index fb4ad7653..b59817c1b 100644 --- a/app/src/main/java/com/getcode/view/main/account/AccountSheetViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/account/AccountSheetViewModel.kt @@ -7,7 +7,7 @@ import com.getcode.R import com.getcode.manager.AuthManager import com.getcode.model.BuyModuleFeature import com.getcode.model.Feature -import com.getcode.model.PrefsBool +import com.getcode.services.model.PrefsBool import com.getcode.network.repository.BetaFlagsRepository import com.getcode.network.repository.BetaOptions import com.getcode.network.repository.FeatureRepository diff --git a/app/src/main/java/com/getcode/view/main/account/AppSettingsScreen.kt b/app/src/main/java/com/getcode/view/main/account/AppSettingsScreen.kt index 93da5645d..6232caad0 100644 --- a/app/src/main/java/com/getcode/view/main/account/AppSettingsScreen.kt +++ b/app/src/main/java/com/getcode/view/main/account/AppSettingsScreen.kt @@ -10,8 +10,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import com.getcode.model.PrefsBool -import com.getcode.ui.components.SettingsRow +import com.getcode.services.model.PrefsBool +import com.getcode.ui.components.SettingsSwitchRow import com.getcode.util.Biometrics import kotlinx.coroutines.launch @@ -27,7 +27,7 @@ fun AppSettingsScreen( LazyColumn { items(state.settings) { option -> if (option.visible) { - SettingsRow( + SettingsSwitchRow( modifier = Modifier.animateItemPlacement(), enabled = option.available, title = stringResource(id = option.name), diff --git a/app/src/main/java/com/getcode/view/main/account/AppSettingsViewModel.kt b/app/src/main/java/com/getcode/view/main/account/AppSettingsViewModel.kt index 510c6de42..e1f2bd8c9 100644 --- a/app/src/main/java/com/getcode/view/main/account/AppSettingsViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/account/AppSettingsViewModel.kt @@ -1,13 +1,9 @@ package com.getcode.view.main.account import androidx.lifecycle.viewModelScope -import com.getcode.R import com.getcode.mapper.AppSettingsMapper -import com.getcode.model.APP_SETTINGS -import com.getcode.model.AppSetting -import com.getcode.model.PrefsBool +import com.getcode.services.model.AppSetting import com.getcode.models.SettingItem -import com.getcode.network.repository.AppSettings import com.getcode.network.repository.AppSettingsRepository import com.getcode.view.BaseViewModel2 import dagger.hilt.android.lifecycle.HiltViewModel diff --git a/app/src/main/java/com/getcode/view/main/account/BackupKey.kt b/app/src/main/java/com/getcode/view/main/account/BackupKey.kt index e724c9a29..3a119ea45 100644 --- a/app/src/main/java/com/getcode/view/main/account/BackupKey.kt +++ b/app/src/main/java/com/getcode/view/main/account/BackupKey.kt @@ -39,17 +39,17 @@ import androidx.compose.ui.unit.isSpecified import com.getcode.R import com.getcode.manager.TopBarManager import com.getcode.theme.CodeTheme -import com.getcode.ui.components.ButtonState +import com.getcode.ui.theme.ButtonState import com.getcode.ui.components.Cloudy -import com.getcode.ui.components.CodeButton -import com.getcode.ui.components.PermissionResult +import com.getcode.ui.theme.CodeButton import com.getcode.ui.components.SelectionContainer -import com.getcode.ui.components.getPermissionLauncher -import com.getcode.ui.components.rememberPermissionChecker import com.getcode.ui.components.rememberSelectionState import com.getcode.ui.utils.addIf import com.getcode.ui.utils.measured import com.getcode.util.launchAppSettings +import com.getcode.util.permissions.PermissionResult +import com.getcode.util.permissions.getPermissionLauncher +import com.getcode.util.permissions.rememberPermissionHandler @Composable fun BackupKey( @@ -79,7 +79,7 @@ fun BackupKey( } val launcher = getPermissionLauncher(Manifest.permission.WRITE_EXTERNAL_STORAGE, onPermissionResult) - val permissionChecker = rememberPermissionChecker() + val permissionChecker = rememberPermissionHandler() if (isExportSeedRequested && isStoragePermissionGranted) { viewModel.onSubmit() isExportSeedRequested = false diff --git a/app/src/main/java/com/getcode/view/main/account/BetaFlagsScreen.kt b/app/src/main/java/com/getcode/view/main/account/BetaFlagsScreen.kt index 17903baca..bf7df81e6 100644 --- a/app/src/main/java/com/getcode/view/main/account/BetaFlagsScreen.kt +++ b/app/src/main/java/com/getcode/view/main/account/BetaFlagsScreen.kt @@ -13,13 +13,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.getcode.R -import com.getcode.model.Immutable -import com.getcode.model.PrefsBool +import com.getcode.services.model.Immutable +import com.getcode.services.model.PrefsBool import com.getcode.network.repository.BetaOptions import com.getcode.theme.CodeTheme -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton -import com.getcode.ui.components.SettingsRow +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.components.SettingsSwitchRow import dev.bmcreations.tipkit.engines.LocalTipsEngine @OptIn(ExperimentalFoundationApi::class) @@ -78,12 +78,6 @@ fun BetaFlagsScreen( stringResource(id = R.string.beta_photo_gallery_description), state.galleryEnabled, ), - BetaFeature( - PrefsBool.CONVERSATIONS_ENABLED, - R.string.beta_conversations, - stringResource(id = R.string.beta_conversations_description), - state.conversationsEnabled, - ), BetaFeature( PrefsBool.VIBRATE_ON_SCAN, R.string.beta_vibrate_on_scan, @@ -154,7 +148,7 @@ fun BetaFlagsScreen( LazyColumn { items(options) { option -> - SettingsRow( + SettingsSwitchRow( modifier = Modifier.animateItemPlacement(), title = stringResource(id = option.titleResId), subtitle = option.subtitleText, @@ -184,7 +178,6 @@ fun BetaFlagsScreen( private fun BetaOptions.canMutate(flag: PrefsBool): Boolean { return when (flag) { is Immutable -> false - PrefsBool.CONVERSATION_CASH_ENABLED -> conversationsEnabled else -> true } } diff --git a/app/src/main/java/com/getcode/view/main/account/BetaFlagsViewModel.kt b/app/src/main/java/com/getcode/view/main/account/BetaFlagsViewModel.kt index a58b01df1..2fc8b8533 100644 --- a/app/src/main/java/com/getcode/view/main/account/BetaFlagsViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/account/BetaFlagsViewModel.kt @@ -1,9 +1,7 @@ package com.getcode.view.main.account -import androidx.compose.ui.res.stringResource import androidx.lifecycle.viewModelScope -import com.getcode.R -import com.getcode.model.PrefsBool +import com.getcode.services.model.PrefsBool import com.getcode.network.repository.BetaFlagsRepository import com.getcode.network.repository.BetaOptions import com.getcode.network.repository.PrefRepository diff --git a/app/src/main/java/com/getcode/view/main/account/BucketDebugger.kt b/app/src/main/java/com/getcode/view/main/account/BucketDebugger.kt index 044fc188f..f27ddd9d2 100644 --- a/app/src/main/java/com/getcode/view/main/account/BucketDebugger.kt +++ b/app/src/main/java/com/getcode/view/main/account/BucketDebugger.kt @@ -15,7 +15,6 @@ import com.getcode.manager.SessionManager import com.getcode.model.displayName import com.getcode.solana.keys.base58 import com.getcode.solana.organizer.AccountType -import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme import com.getcode.ui.components.MiddleEllipsisText import com.getcode.ui.utils.rememberedClickable @@ -98,7 +97,7 @@ fun BucketDebugger() { Spacer(modifier = Modifier .padding(vertical = CodeTheme.dimens.grid.x2) - .background(BrandLight) + .background(CodeTheme.colors.brandLight) .fillMaxWidth() .height(1.dp)) } diff --git a/app/src/main/java/com/getcode/view/main/account/ConfirmDeleteAccount.kt b/app/src/main/java/com/getcode/view/main/account/ConfirmDeleteAccount.kt index be1a04ac8..7e8b1bb4d 100644 --- a/app/src/main/java/com/getcode/view/main/account/ConfirmDeleteAccount.kt +++ b/app/src/main/java/com/getcode/view/main/account/ConfirmDeleteAccount.kt @@ -22,8 +22,8 @@ import com.getcode.theme.CodeTheme import com.getcode.theme.extraSmall import com.getcode.theme.inputColors import com.getcode.ui.utils.getActivity -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton @OptIn(ExperimentalComposeUiApi::class) @Composable diff --git a/app/src/main/java/com/getcode/view/main/account/DeleteAccount.kt b/app/src/main/java/com/getcode/view/main/account/DeleteAccount.kt index d4b406b06..6ba905e39 100644 --- a/app/src/main/java/com/getcode/view/main/account/DeleteAccount.kt +++ b/app/src/main/java/com/getcode/view/main/account/DeleteAccount.kt @@ -14,8 +14,8 @@ import com.getcode.R import com.getcode.navigation.screens.DeleteConfirmationScreen import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton import com.getcode.ui.components.TextSection @Composable diff --git a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddress.kt b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddress.kt index da1e87b8c..3883bd9cf 100644 --- a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddress.kt +++ b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddress.kt @@ -18,7 +18,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -29,8 +28,8 @@ import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.WithdrawalArgs import com.getcode.theme.CodeTheme import com.getcode.theme.Success -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton import com.getcode.ui.components.TextInput @OptIn(ExperimentalFoundationApi::class) diff --git a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddressViewModel.kt b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddressViewModel.kt index 0077bc2dc..31d02b6b6 100644 --- a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddressViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddressViewModel.kt @@ -3,9 +3,9 @@ package com.getcode.view.main.account.withdraw import android.annotation.SuppressLint import android.content.ClipboardManager import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.text2.input.TextFieldState -import androidx.compose.foundation.text2.input.setTextAndPlaceCursorAtEnd -import androidx.compose.foundation.text2.input.textAsFlow +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.viewModelScope import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.screens.WithdrawalArgs @@ -44,7 +44,6 @@ data class AccountWithdrawAddressUiModel( ) @HiltViewModel -@OptIn(ExperimentalFoundationApi::class) class AccountWithdrawAddressViewModel @Inject constructor( private val client: Client, private val clipboard: ClipboardManager, @@ -55,7 +54,7 @@ class AccountWithdrawAddressViewModel @Inject constructor( init { uiFlow.map { it.addressText } - .flatMapLatest { it.textAsFlow() } + .flatMapLatest { snapshotFlow { it.text } } .map { it.toString() } .debounce(300.milliseconds) .onEach { updated -> setAddress(updated) } diff --git a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAmount.kt b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAmount.kt index 39bf42a97..23d7eeda6 100644 --- a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAmount.kt +++ b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAmount.kt @@ -15,12 +15,11 @@ import com.getcode.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.CurrencySelectionModal import com.getcode.theme.Alert -import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton -import com.getcode.ui.components.CodeKeyPad -import com.getcode.view.main.giveKin.AmountArea +import com.getcode.ui.components.text.AmountArea +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeKeyPad @Composable fun AccountWithdrawAmount( @@ -36,7 +35,7 @@ fun AccountWithdrawAmount( horizontalAlignment = Alignment.CenterHorizontally ) { val color = - if (dataState.amountModel.balanceKin < dataState.amountModel.amountKin.toKinValueDouble()) Alert else BrandLight + if (dataState.amountModel.balanceKin < dataState.amountModel.amountKin.toKinValueDouble()) CodeTheme.colors.errorText else CodeTheme.colors.brandLight Box( modifier = Modifier.weight(0.5f) diff --git a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAmountViewModel.kt b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAmountViewModel.kt index 2fabac7f1..3d0dc2ab3 100644 --- a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAmountViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAmountViewModel.kt @@ -11,12 +11,9 @@ import com.getcode.network.exchange.Exchange import com.getcode.network.repository.BalanceRepository import com.getcode.network.repository.PrefRepository import com.getcode.network.repository.TransactionRepository -import com.getcode.util.CurrencyUtils -import com.getcode.util.locale.LocaleHelper import com.getcode.util.resources.ResourceHelper import com.getcode.utils.ErrorUtils -import com.getcode.utils.network.NetworkConnectivityListener -import com.getcode.view.main.giveKin.AmountAnimatedInputUiModel +import com.getcode.ui.components.text.AmountAnimatedInputUiModel import com.getcode.view.main.giveKin.AmountUiModel import com.getcode.view.main.giveKin.BaseAmountCurrencyViewModel import com.getcode.view.main.giveKin.CurrencyUiModel @@ -42,9 +39,9 @@ class AccountWithdrawAmountViewModel @Inject constructor( prefsRepository: PrefRepository, balanceRepository: BalanceRepository, transactionRepository: TransactionRepository, - localeHelper: LocaleHelper, - currencyUtils: CurrencyUtils, - networkObserver: NetworkConnectivityListener, + localeHelper: com.getcode.util.locale.LocaleHelper, + currencyUtils: com.getcode.utils.CurrencyUtils, + networkObserver: com.getcode.utils.network.NetworkConnectivityListener, resources: ResourceHelper, ) : BaseAmountCurrencyViewModel( client, diff --git a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawSummary.kt b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawSummary.kt index d2d28543c..d122ecb2f 100644 --- a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawSummary.kt +++ b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawSummary.kt @@ -3,9 +3,19 @@ package com.getcode.view.main.account.withdraw import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -15,12 +25,10 @@ import androidx.constraintlayout.compose.ConstraintLayout import com.getcode.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.WithdrawalArgs -import com.getcode.theme.Brand01 -import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton -import com.getcode.view.main.giveKin.AmountArea +import com.getcode.ui.components.text.AmountArea +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton @Composable fun AccountWithdrawSummary( @@ -49,8 +57,8 @@ fun AccountWithdrawSummary( ) { Box( modifier = Modifier - .border(width = CodeTheme.dimens.border, color = BrandLight, shape = CodeTheme.shapes.medium) - .background(Brand01) + .border(width = CodeTheme.dimens.border, color = CodeTheme.colors.brandLight, shape = CodeTheme.shapes.medium) + .background(CodeTheme.colors.brandDark) .padding(CodeTheme.dimens.grid.x4) ) { AmountArea( @@ -73,8 +81,8 @@ fun AccountWithdrawSummary( Box( modifier = Modifier - .border(width = CodeTheme.dimens.border, color = BrandLight, shape = CodeTheme.shapes.medium) - .background(Brand01) + .border(width = CodeTheme.dimens.border, color = CodeTheme.colors.brandLight, shape = CodeTheme.shapes.medium) + .background(CodeTheme.colors.brandDark) .padding(CodeTheme.dimens.grid.x4) ) { Text( diff --git a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawSummaryViewModel.kt b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawSummaryViewModel.kt index 1720b81f9..0f91fe859 100644 --- a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawSummaryViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawSummaryViewModel.kt @@ -4,8 +4,8 @@ import android.annotation.SuppressLint import androidx.lifecycle.viewModelScope import com.getcode.R import com.getcode.analytics.AnalyticsService -import com.getcode.solana.keys.PublicKey import com.getcode.manager.BottomBarManager +import com.getcode.solana.keys.PublicKey import com.getcode.manager.SessionManager import com.getcode.manager.TopBarManager import com.getcode.model.CurrencyCode @@ -19,12 +19,12 @@ import com.getcode.network.NotificationCollectionHistoryController import com.getcode.network.client.* import com.getcode.util.resources.ResourceHelper import com.getcode.utils.ErrorUtils -import com.getcode.vendor.Base58 import com.getcode.view.* import dagger.hilt.android.lifecycle.HiltViewModel import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import org.kin.sdk.base.tools.Base58 import javax.inject.Inject private const val TAG = "AccountWithdrawSummaryViewModel" @@ -105,7 +105,8 @@ class AccountWithdrawSummaryViewModel @Inject constructor( ) val organizer = SessionManager.getOrganizer() ?: return - val destination = PublicKey(Base58.decode(uiModel.resolvedDestination).toList()) + val destination = + PublicKey(Base58.decode(uiModel.resolvedDestination).toList()) client.withdrawExternally(amount, organizer, destination) .observeOn(AndroidSchedulers.mainThread()) diff --git a/app/src/main/java/com/getcode/view/main/balance/BalanceSheet.kt b/app/src/main/java/com/getcode/view/main/balance/BalanceSheet.kt index be30f4b38..b38474f4a 100644 --- a/app/src/main/java/com/getcode/view/main/balance/BalanceSheet.kt +++ b/app/src/main/java/com/getcode/view/main/balance/BalanceSheet.kt @@ -33,31 +33,27 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.navigator.currentOrThrow -import com.getcode.LocalSession import com.getcode.R import com.getcode.manager.TopBarManager import com.getcode.model.Currency import com.getcode.model.CurrencyCode import com.getcode.model.Rate import com.getcode.model.chat.Chat -import com.getcode.model.chat.isConversation import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.BuyMoreKinModal -import com.getcode.navigation.screens.ConversationScreen import com.getcode.navigation.screens.NotificationCollectionScreen import com.getcode.navigation.screens.CurrencySelectionModal import com.getcode.navigation.screens.FaqScreen import com.getcode.theme.CodeTheme +import com.getcode.theme.DesignSystem import com.getcode.theme.White10 -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton -import com.getcode.ui.components.CodeCircularProgressIndicator -import com.getcode.ui.components.chat.ChatNode -import com.getcode.util.Kin +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeCircularProgressIndicator +import com.getcode.utils.Kin import com.getcode.view.main.account.BucketDebugger import com.getcode.view.main.currency.CurrencySelectKind -import com.getcode.view.main.giveKin.AmountArea +import com.getcode.ui.components.text.AmountArea @Composable @@ -185,7 +181,7 @@ fun BalanceContent( ChatNode(chat = chat, onClick = { openChat(chat) }) Divider( modifier = Modifier.padding(start = CodeTheme.dimens.inset), - color = White10, + color = CodeTheme.colors.divider, ) } @@ -363,7 +359,7 @@ private fun EmptyTransactionsHint(faqOpen: () -> Unit) { @Preview @Composable private fun TopPreview() { - CodeTheme { + DesignSystem { val model = BalanceSheetViewModel.State( amountText = "$12.34 of Kin", marketValue = 2_225_100.0, diff --git a/app/src/main/java/com/getcode/view/main/balance/BalanceSheetViewModel.kt b/app/src/main/java/com/getcode/view/main/balance/BalanceSheetViewModel.kt index 1ab626dc6..3ab43ddb1 100644 --- a/app/src/main/java/com/getcode/view/main/balance/BalanceSheetViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/balance/BalanceSheetViewModel.kt @@ -6,20 +6,17 @@ import com.getcode.model.BuyModuleFeature import com.getcode.model.chat.Chat import com.getcode.model.Currency import com.getcode.model.Feature -import com.getcode.model.PrefsBool +import com.getcode.services.model.PrefsBool import com.getcode.model.Rate import com.getcode.network.BalanceController import com.getcode.network.NotificationCollectionHistoryController import com.getcode.network.repository.FeatureRepository import com.getcode.network.repository.PrefRepository -import com.getcode.util.Kin -import com.getcode.utils.network.NetworkConnectivityListener +import com.getcode.utils.Kin import com.getcode.view.BaseViewModel2 import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -33,7 +30,7 @@ class BalanceSheetViewModel @Inject constructor( history: NotificationCollectionHistoryController, prefsRepository: PrefRepository, features: FeatureRepository, - networkObserver: NetworkConnectivityListener, + networkObserver: com.getcode.utils.network.NetworkConnectivityListener, ) : BaseViewModel2( initialState = State(), updateStateForEvent = updateStateForEvent @@ -122,12 +119,6 @@ class BalanceSheetViewModel @Inject constructor( }.onEach { dispatchEvent(Dispatchers.Main, Event.OnChatsLoading(false)) }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .filter { features.isEnabled(PrefsBool.CONVERSATIONS_ENABLED) } - .onEach { history.fetch(true) } - .launchIn(viewModelScope) } companion object { diff --git a/app/src/main/java/com/getcode/view/main/balance/ChatNode.kt b/app/src/main/java/com/getcode/view/main/balance/ChatNode.kt new file mode 100644 index 000000000..aa0b08bcc --- /dev/null +++ b/app/src/main/java/com/getcode/view/main/balance/ChatNode.kt @@ -0,0 +1,46 @@ +package com.getcode.view.main.balance + +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import com.getcode.model.chat.Chat +import com.getcode.model.chat.MessageContent +import com.getcode.ui.components.chat.ChatNode +import com.getcode.ui.components.chat.utils.localized +import com.getcode.ui.components.chat.utils.localizedText + +@Composable +fun ChatNode( + modifier: Modifier = Modifier, + chat: Chat, + showAvatar: Boolean = false, + onClick: () -> Unit, +) { + ChatNode( + modifier = modifier, + title = chat.title.localized, + messagePreview = chat.messagePreview, + avatar = if (showAvatar) chat.imageData else null, + timestamp = chat.lastMessageMillis, + isMuted = chat.isMuted, + unreadCount = chat.unreadCount, + onClick = onClick + ) +} + +private val Chat.messagePreview: Pair> + @Composable get() { + val contents = newestMessage?.contents ?: return AnnotatedString("No content") to emptyMap() + + var filtered: List = contents.filterIsInstance() + if (filtered.isEmpty()) { + filtered = contents + } + + // joinToString does expose a Composable scoped lambda + @Suppress("SimplifiableCallChain") + val messageBody = filtered.map { it.localizedText }.joinToString(" ") + + return AnnotatedString(messageBody) to emptyMap() + } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/bill/AnimatedBill.kt b/app/src/main/java/com/getcode/view/main/bill/AnimatedBill.kt index 220b6a381..102528977 100644 --- a/app/src/main/java/com/getcode/view/main/bill/AnimatedBill.kt +++ b/app/src/main/java/com/getcode/view/main/bill/AnimatedBill.kt @@ -15,7 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.getcode.models.Bill -import com.getcode.ui.components.CustomSwipeToDismiss +import com.getcode.ui.theme.CustomSwipeToDismiss @OptIn(ExperimentalMaterialApi::class) @Composable diff --git a/app/src/main/java/com/getcode/view/main/bill/Bill.kt b/app/src/main/java/com/getcode/view/main/bill/Bill.kt index ca6c8bacb..4c0b6cfb5 100644 --- a/app/src/main/java/com/getcode/view/main/bill/Bill.kt +++ b/app/src/main/java/com/getcode/view/main/bill/Bill.kt @@ -6,13 +6,14 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import com.getcode.model.CodePayload +import com.getcode.services.model.CodePayload import com.getcode.model.CurrencyCode import com.getcode.model.Fiat import com.getcode.model.KinAmount -import com.getcode.model.Kind +import com.getcode.services.model.Kind +import com.getcode.model.fromFiatAmount import com.getcode.models.Bill -import com.getcode.theme.CodeTheme +import com.getcode.theme.DesignSystem @Composable fun Bill( @@ -50,7 +51,7 @@ fun Bill( @Preview @Composable fun Preview_CashBill() { - CodeTheme { + DesignSystem { val payload = CodePayload( Kind.Cash, value = Fiat(CurrencyCode.USD, 3.00), @@ -73,7 +74,7 @@ fun Preview_CashBill() { @Preview @Composable fun Preview_PaymentBill() { - CodeTheme { + DesignSystem { val payload = CodePayload( Kind.RequestPayment, value = Fiat(CurrencyCode.USD, 0.25), diff --git a/app/src/main/java/com/getcode/view/main/bill/BillManagementOptions.kt b/app/src/main/java/com/getcode/view/main/bill/BillManagementOptions.kt index af81434b2..01dbf3022 100644 --- a/app/src/main/java/com/getcode/view/main/bill/BillManagementOptions.kt +++ b/app/src/main/java/com/getcode/view/main/bill/BillManagementOptions.kt @@ -18,7 +18,7 @@ import androidx.compose.ui.unit.dp import com.getcode.models.BillState import com.getcode.theme.CodeTheme import com.getcode.theme.White -import com.getcode.ui.components.CodeCircularProgressIndicator +import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.components.Pill import com.getcode.ui.utils.rememberedClickable diff --git a/app/src/main/java/com/getcode/view/main/bill/CashBill.kt b/app/src/main/java/com/getcode/view/main/bill/CashBill.kt index 77da6a1fb..50ff18dba 100644 --- a/app/src/main/java/com/getcode/view/main/bill/CashBill.kt +++ b/app/src/main/java/com/getcode/view/main/bill/CashBill.kt @@ -60,7 +60,7 @@ import com.getcode.ui.utils.drawWithGradient import com.getcode.ui.utils.nonScaledSp import com.getcode.ui.utils.punchCircle import com.getcode.ui.utils.punchRectangle -import com.getcode.util.formattedRaw +import com.getcode.extensions.formattedRaw import kotlin.math.ceil import kotlin.math.roundToInt diff --git a/app/src/main/java/com/getcode/view/main/bill/PaymentReceiptBill.kt b/app/src/main/java/com/getcode/view/main/bill/PaymentReceiptBill.kt index aacd09fd1..7936e1666 100644 --- a/app/src/main/java/com/getcode/view/main/bill/PaymentReceiptBill.kt +++ b/app/src/main/java/com/getcode/view/main/bill/PaymentReceiptBill.kt @@ -32,7 +32,7 @@ import com.getcode.theme.CodeTheme import com.getcode.theme.DashEffect import com.getcode.theme.receipt import com.getcode.theme.monospace -import com.getcode.view.main.scanner.components.PriceWithFlag +import com.getcode.ui.components.PriceWithFlag @OptIn(ExperimentalLayoutApi::class) @Composable diff --git a/app/src/main/java/com/getcode/view/main/bill/TipCard.kt b/app/src/main/java/com/getcode/view/main/bill/TipCard.kt index c3fe267f5..bfe1ce175 100644 --- a/app/src/main/java/com/getcode/view/main/bill/TipCard.kt +++ b/app/src/main/java/com/getcode/view/main/bill/TipCard.kt @@ -29,17 +29,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.scale import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.drawscope.inset -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -51,12 +45,11 @@ import com.getcode.R import com.getcode.theme.Brand import com.getcode.theme.CodeTheme import com.getcode.ui.components.CardFace -import com.getcode.ui.components.CodeCircularProgressIndicator +import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.components.FlippableCard import com.getcode.ui.components.Row import com.getcode.ui.components.TwitterUsernameDisplay import com.getcode.ui.utils.Geometry -import com.getcode.ui.utils.rememberedLongClickable import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeStyle import dev.chrisbanes.haze.haze diff --git a/app/src/main/java/com/getcode/view/main/chat/ChatScreen.kt b/app/src/main/java/com/getcode/view/main/chat/ChatScreen.kt index ddee65658..1ea6e5b05 100644 --- a/app/src/main/java/com/getcode/view/main/chat/ChatScreen.kt +++ b/app/src/main/java/com/getcode/view/main/chat/ChatScreen.kt @@ -15,8 +15,8 @@ import androidx.compose.ui.unit.Dp import androidx.paging.compose.LazyPagingItems import com.getcode.R import com.getcode.manager.BottomBarManager -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton import com.getcode.ui.components.Row import com.getcode.ui.components.VerticalDivider import com.getcode.ui.components.chat.MessageList @@ -41,12 +41,6 @@ fun ChatScreen( modifier = Modifier.weight(1f), listState = listState, messages = messages, - dispatch = { - when (it) { - is MessageListEvent.OpenMessageChat -> dispatch(NotificationCollectionViewModel.Event.OpenMessageChat(it.reference)) - is MessageListEvent.AdvancePointer -> Unit // handled on conversation open - } - } ) Row( diff --git a/app/src/main/java/com/getcode/view/main/chat/NotificationCollectionViewModel.kt b/app/src/main/java/com/getcode/view/main/chat/NotificationCollectionViewModel.kt index 84a1aaf4d..682e9bdb4 100644 --- a/app/src/main/java/com/getcode/view/main/chat/NotificationCollectionViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/chat/NotificationCollectionViewModel.kt @@ -5,20 +5,20 @@ import androidx.paging.flatMap import androidx.paging.insertSeparators import androidx.paging.map import com.getcode.model.ID -import com.getcode.model.MessageStatus import com.getcode.model.chat.MessageContent +import com.getcode.model.chat.MessageStatus import com.getcode.model.chat.NotificationCollectionEntity import com.getcode.model.chat.Reference +import com.getcode.model.chat.Sender import com.getcode.model.chat.Title import com.getcode.model.chat.Verb -import com.getcode.network.ConversationController import com.getcode.network.NotificationCollectionHistoryController import com.getcode.network.repository.BetaFlagsRepository -import com.getcode.network.repository.base58 import com.getcode.ui.components.chat.utils.ChatItem import com.getcode.ui.components.chat.utils.ChatMessageIndice import com.getcode.util.formatDateRelatively import com.getcode.util.toInstantFromMillis +import com.getcode.utils.base58 import com.getcode.view.BaseViewModel2 import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers @@ -41,7 +41,6 @@ import javax.inject.Inject @HiltViewModel class NotificationCollectionViewModel @Inject constructor( historyController: NotificationCollectionHistoryController, - conversationController: ConversationController, betaFlags: BetaFlagsRepository, ) : BaseViewModel2( initialState = State( @@ -160,30 +159,26 @@ class NotificationCollectionViewModel @Inject constructor( } .mapLatest { page -> page.map { (message, contents) -> - val content = - if (contents is MessageContent.Exchange && contents.verb is Verb.ReceivedTip) { - val hasMessaged = conversationController.hasInteracted(message.id) - contents.copy(hasInteracted = hasMessaged) - } else { - contents - } - ChatItem.Message( chatMessageId = message.id, - message = content, + message = contents, date = message.dateMillis.toInstantFromMillis(), - status = if (message.isFromSelf) MessageStatus.Sent else MessageStatus.Unknown, - isFromSelf = message.isFromSelf + status = if (contents.isFromSelf) MessageStatus.Sent else MessageStatus.Unknown, + sender = Sender( + id = null, + displayName = null, + profileImage = null, + isHost = false, + isSelf = contents.isFromSelf, + + ) ) } } .mapLatest { page -> page.insertSeparators { before: ChatItem.Message?, after: ChatItem.Message? -> - val beforeDate = before?.date?.formatDateRelatively() - val afterDate = after?.date?.formatDateRelatively() - - if (beforeDate != afterDate) { - beforeDate?.let { ChatItem.Date(it) } + if (before?.date != after?.date) { + before?.date?.let { ChatItem.Date(it) } } else { null } diff --git a/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationScreen.kt b/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationScreen.kt deleted file mode 100644 index f128eb354..000000000 --- a/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationScreen.kt +++ /dev/null @@ -1,204 +0,0 @@ -@file:OptIn(ExperimentalFoundationApi::class) - -package com.getcode.view.main.chat.conversation - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.ClickableText -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.neverEqualPolicy -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.paging.compose.LazyPagingItems -import cafe.adriel.voyager.navigator.currentOrThrow -import com.getcode.LocalSession -import com.getcode.R -import com.getcode.SessionEvent -import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.ConnectAccount -import com.getcode.theme.CodeTheme -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton -import com.getcode.ui.components.CodeScaffold -import com.getcode.ui.components.chat.utils.ChatItem -import com.getcode.ui.components.chat.ChatInput -import com.getcode.ui.components.chat.MessageList -import com.getcode.ui.components.chat.MessageListEvent -import com.getcode.ui.components.chat.TypingIndicator -import com.getcode.ui.components.chat.utils.HandleMessageChanges -import com.getcode.util.formatted -import com.getcode.view.main.tip.IdentityConnectionReason -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import java.util.UUID - -@Composable -fun ConversationScreen( - state: ConversationViewModel.State, - messages: LazyPagingItems, - dispatchEvent: (ConversationViewModel.Event) -> Unit, -) { - val navigator = LocalCodeNavigator.current - - CodeScaffold( - topBar = { - IdentityRevealHeader(state = state) { - if (state.identityAvailable) { - dispatchEvent(ConversationViewModel.Event.RevealIdentity) - } else { - navigator.push(ConnectAccount(IdentityConnectionReason.IdentityReveal)) - } - } - }, - bottomBar = { - Column( - modifier = Modifier.imePadding(), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), - ) { - val canChat = remember(state.twitterUser) { - state.twitterUser == null || state.twitterUser.isFriend - } - if (canChat) { - AnimatedVisibility( - visible = state.showTypingIndicator, - enter = slideInVertically( - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ) - ) { it } + scaleIn() + fadeIn(), - exit = fadeOut() + scaleOut() + slideOutVertically { it } - ) { - TypingIndicator( - modifier = Modifier - .padding(horizontal = CodeTheme.dimens.grid.x2) - ) - } - ChatInput( - state = state.textFieldState, - sendCashEnabled = state.tipChatCash.enabled, - onSendMessage = { dispatchEvent(ConversationViewModel.Event.SendMessage) }, - onSendCash = { dispatchEvent(ConversationViewModel.Event.SendCash) } - ) - } else { - CodeButton( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = CodeTheme.dimens.grid.x2) - .padding(horizontal = CodeTheme.dimens.inset), - buttonState = ButtonState.Filled, - text = stringResource( - R.string.action_payToChat, - state.costToChat.formatted(suffix = "") - ) - ) { - dispatchEvent(ConversationViewModel.Event.PresentPaymentConfirmation) - } - } - } - } - ) { padding -> - val lazyListState = rememberLazyListState() - MessageList( - modifier = Modifier - .fillMaxSize() - .padding(padding), - messages = messages, - listState = lazyListState, - dispatch = { event -> - if (event is MessageListEvent.AdvancePointer) { - dispatchEvent(ConversationViewModel.Event.MarkRead(event.messageId)) - } - } - ) - - HandleMessageChanges(listState = lazyListState, items = messages) { message -> - dispatchEvent(ConversationViewModel.Event.MarkDelivered(message.chatMessageId)) - } - } -} - -@Composable -private fun IdentityRevealHeader( - state: ConversationViewModel.State, - onClick: () -> Unit -) { - var showRevealHeader by remember { - mutableStateOf(false) - } - - LaunchedEffect(state.identityRevealed, state.users) { - if (state.identityRevealed == false) { - delay(500) - } - showRevealHeader = state.identityRevealed == false - } - - AnimatedContent( - targetState = showRevealHeader, - label = "show/hide identity reveal header" - ) { show -> - if (show) { - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = CodeTheme.dimens.grid.x2), - color = CodeTheme.colors.background, - ) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Your messages are showing up anonymously.", - style = CodeTheme.typography.linkSmall.copy( - textDecoration = null, - fontWeight = FontWeight.W700), - ) - - ClickableText( - text = AnnotatedString("Tap to Reveal Your Identity"), - style = CodeTheme.typography.linkSmall.copy( - color = Color.White, - fontWeight = FontWeight.W700 - ), - ) { - onClick() - } - } - } - } - } - -} - diff --git a/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt b/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt deleted file mode 100644 index ed7bb423e..000000000 --- a/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt +++ /dev/null @@ -1,575 +0,0 @@ -@file:OptIn(ExperimentalFoundationApi::class) - -package com.getcode.view.main.chat.conversation - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.text2.input.TextFieldState -import androidx.compose.foundation.text2.input.clearText -import androidx.compose.foundation.text2.input.textAsFlow -import androidx.lifecycle.viewModelScope -import androidx.paging.PagingData -import androidx.paging.flatMap -import androidx.paging.map -import com.getcode.BuildConfig -import com.getcode.R -import com.getcode.SessionController -import com.getcode.SessionEvent -import com.getcode.manager.BottomBarManager -import com.getcode.manager.TopBarManager -import com.getcode.model.ConversationWithLastPointers -import com.getcode.model.Feature -import com.getcode.model.ID -import com.getcode.model.MessageStatus -import com.getcode.model.ConversationCashFeature -import com.getcode.model.KinAmount -import com.getcode.model.TwitterUser -import com.getcode.model.chat.Platform -import com.getcode.model.chat.Reference -import com.getcode.model.uuid -import com.getcode.network.ConversationController -import com.getcode.network.TipController -import com.getcode.network.exchange.Exchange -import com.getcode.network.repository.FeatureRepository -import com.getcode.ui.components.chat.utils.ChatItem -import com.getcode.ui.components.chat.utils.ConversationMessageIndice -import com.getcode.util.resources.ResourceHelper -import com.getcode.util.toInstantFromMillis -import com.getcode.utils.ErrorUtils -import com.getcode.utils.TraceType -import com.getcode.utils.timestamp -import com.getcode.utils.trace -import com.getcode.view.BaseViewModel2 -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.datetime.Instant -import timber.log.Timber -import java.util.UUID -import javax.inject.Inject - -@HiltViewModel -class ConversationViewModel @Inject constructor( - private val conversationController: ConversationController, - features: FeatureRepository, - tipController: TipController, - exchange: Exchange, - resources: ResourceHelper, - sessionController: SessionController, -) : BaseViewModel2( - initialState = State.Default, - updateStateForEvent = updateStateForEvent -) { - - data class State( - val conversationId: ID?, - val twitterUser: TwitterUser?, - val costToChat: KinAmount, - val reference: Reference.IntentId?, - val textFieldState: TextFieldState, - val tipChatCash: Feature, - val identityAvailable: Boolean, - val identityRevealed: Boolean?, - val users: List, - val lastSeen: Instant?, - val pointers: Map, - val showTypingIndicator: Boolean, - val isSelfTyping: Boolean, - ) { - data class User( - val memberId: UUID, - val username: String?, - val imageUrl: String?, - ) { - val isRevealed: Boolean - get() = username != null - } - - companion object { - val Default = State( - twitterUser = null, - costToChat = KinAmount.Zero, - conversationId = null, - reference = null, - tipChatCash = ConversationCashFeature(), - textFieldState = TextFieldState(), - identityAvailable = false, - identityRevealed = null, - users = emptyList(), - lastSeen = null, - pointers = emptyMap(), - showTypingIndicator = false, - isSelfTyping = false, - ) - } - } - - sealed interface Event { - data class OnTwitterUserChanged(val user: TwitterUser?) : Event - data class OnCostToChatChanged(val cost: KinAmount) : Event - data class OnMembersChanged(val members: List) : Event - data class OnChatIdChanged(val chatId: ID?) : Event - data class OnConversationChanged(val conversationWithPointers: ConversationWithLastPointers) : - Event - - data class OnUserRevealed( - val memberId: UUID, - val username: String? = null, - val imageUrl: String? = null, - ) : Event - - data class OnTipsChatCashChanged(val module: Feature) : Event - - data class OnUserActivity(val activity: Instant) : Event - data object SendCash : Event - data object SendMessage : Event - data object RevealIdentity : Event - - data class OnIdentityAvailable(val available: Boolean) : Event - data object OnIdentityRevealed : Event - - data class OnPointersUpdated(val pointers: Map) : Event - data class MarkRead(val messageId: ID) : Event - data class MarkDelivered(val messageId: ID) : Event - - data object PresentPaymentConfirmation : Event - - data object OnTypingStarted: Event - data object OnTypingStopped: Event - - data object OnUserTypingStarted: Event - data object OnUserTypingStopped: Event - - data class Error(val fatal: Boolean, val message: String = "", val show: Boolean = true) : - Event - } - - init { - // this is an existing conversation so we fetch the chat directly - eventFlow - .filterIsInstance() - .map { it.chatId } - .filterNotNull() - .mapNotNull { - conversationController.getConversation(it) - }.onEach { - dispatchEvent(Event.OnConversationChanged(it)) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .map { it.user } - .filterNotNull() - .mapNotNull { user -> - val currencySymbol = user.costOfFriendship.currency - val rate = exchange.rateFor(currencySymbol) ?: exchange.rateForUsd()!! - - user to KinAmount.fromFiatAmount(fiat = user.costOfFriendship, rate = rate) - }.map { (user, cost) -> - dispatchEvent(Event.OnCostToChatChanged(cost)) - user - }.onEach { user -> - val member = user.let { - State.User( - memberId = UUID.randomUUID(), - username = user.username, - imageUrl = user.imageUrl - ) - } - - dispatchEvent(Event.OnMembersChanged(listOf(member))) - } - .launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .mapNotNull { - val state = stateFlow.value - if (state.twitterUser == null) return@mapNotNull null - state.twitterUser to state.costToChat - }.onEach { (user, amount) -> - sessionController.presentPrivatePaymentConfirmation( - socialUser = user, - amount = amount - ) - }.launchIn(viewModelScope) - - sessionController.eventFlow - .filterIsInstance() - .onEach { event -> - runCatching { - val conversation = conversationController.getOrCreateConversation( - identifier = event.intentId, - with = event.user - ) - dispatchEvent(Event.OnConversationChanged(conversation)) - }.onFailure { - it.printStackTrace() - TopBarManager.showMessage( - "Failed to Start Chat", - "We were unable to start a chat with ${event.user.username}. Please try again.", - ) - - dispatchEvent( - Event.Error( - message = if (BuildConfig.DEBUG) it.message.orEmpty() else "Failed to create conversation", - show = false, - fatal = true - ) - ) - }.getOrNull() - } - .launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .map { it.conversationWithPointers } - .distinctUntilChangedBy { it.conversation.id } - .onEach { conversationController.resetUnreadCount(it.conversation.id) } - .onEach { (conversation, _) -> - runCatching { - conversationController.openChatStream(viewModelScope, conversation) - }.onFailure { - it.printStackTrace() - ErrorUtils.handleError(it) - } - }.flatMapLatest { (conversation, _) -> - conversationController.observeConversation(conversation.id) - }.filterNotNull() - .distinctUntilChanged() - .onEach { dispatchEvent(Event.OnConversationChanged(it)) } - .launchIn(viewModelScope) - - features.conversationsCash - .onEach { dispatchEvent(Event.OnTipsChatCashChanged(it)) } - .launchIn(viewModelScope) - - tipController.connectedAccount - .onEach { - dispatchEvent(Event.OnIdentityAvailable(it != null)) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .map { it.messageId } - .filter { stateFlow.value.conversationId != null } - .map { it to stateFlow.value.conversationId!! } - .onEach { (messageId, conversationId) -> - conversationController.advanceReadPointer( - conversationId, - messageId, - MessageStatus.Read - ) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .map { it.messageId } - .filter { stateFlow.value.conversationId != null } - .map { it to stateFlow.value.conversationId!! } - .onEach { (messageId, conversationId) -> - conversationController.advanceReadPointer( - conversationId, - messageId, - MessageStatus.Delivered - ) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .map { stateFlow.value } - .onEach { - val textFieldState = it.textFieldState - val text = textFieldState.text.toString() - textFieldState.clearText() - - conversationController.sendMessage(it.conversationId!!, text) - .onSuccess { - trace( - tag = "Conversation", - message = "message sent successfully", - type = TraceType.Silent - ) - } - .onFailure { error -> - trace( - tag = "Conversation", - message = "message failed to send", - type = TraceType.Error, - error = error - ) - } - } - .launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .mapNotNull { stateFlow.value.conversationId } - .onEach { conversationId -> - val user = stateFlow.value.users.firstOrNull()?.username ?: "This user" - val identity = tipController.connectedAccount.value ?: return@onEach - val platform = when (identity) { - is TwitterUser -> Platform.Twitter - } - BottomBarManager.showMessage( - BottomBarManager.BottomBarMessage( - title = resources.getString(R.string.prompt_title_revealIdentity), - subtitle = resources.getString( - R.string.prompt_subtitle_revealIdentity, - user, - identity.username - ), - positiveText = resources.getString(R.string.action_yes), - type = BottomBarManager.BottomBarMessageType.REMOTE_SEND, - onPositive = { - viewModelScope.launch { - conversationController.revealIdentity( - conversationId, - platform, - identity.username - ).onSuccess { dispatchEvent(Event.OnIdentityRevealed) } - .onFailure { it.printStackTrace() } - } - }, - negativeText = resources.getString(R.string.action_nevermind) - ) - ) - } - .launchIn(viewModelScope) - - stateFlow - .mapNotNull { it.users } - .distinctUntilChanged() - .flatMapLatest { users -> - users.asFlow() // Convert the list to a flow - } - .filter { it.username != null } - .mapNotNull { user -> - val username = user.username ?: return@mapNotNull null - runCatching { tipController.fetch(username) } - .getOrNull() to user - } - .onEach { (result, user) -> - if (result != null) { - dispatchEvent( - Event.OnUserRevealed( - memberId = user.memberId, - result.username, - result.imageUrl - ) - ) - } - } - .launchIn(viewModelScope) - - stateFlow - .map { it.conversationId } - .filterNotNull() - .distinctUntilChanged() - .flatMapLatest { conversationController.observeTyping(it) } - .onEach { isOtherUserTyping -> - if (isOtherUserTyping) { - dispatchEvent(Event.OnTypingStarted) - } else { - dispatchEvent(Event.OnTypingStopped) - } - }.launchIn(viewModelScope) - - - stateFlow - .map { it.textFieldState } - .flatMapLatest { it.textAsFlow() } - .onEach { - if (it.isEmpty()) { - dispatchEvent(Event.OnUserTypingStopped) - } else if (it.isNotEmpty()) { - if (!stateFlow.value.isSelfTyping) { - dispatchEvent(Event.OnUserTypingStarted) - } - } - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .mapNotNull { stateFlow.value.conversationId } - .onEach { - conversationController.onUserStartedTypingIn(it) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .mapNotNull { stateFlow.value.conversationId } - .onEach { - conversationController.onUserStoppedTypingIn(it) - }.launchIn(viewModelScope) - } - - val messages: Flow> = stateFlow - .map { it.conversationId } - .filterNotNull() - .flatMapLatest { conversationController.conversationPagingData(it) } - .map { page -> - page.flatMap { mwc -> - mwc.contents.map { ConversationMessageIndice(mwc.message, it) } - } - } - .map { page -> - page.map { indice -> - val (message, contents) = indice - - val pointers = stateFlow.value.pointers - val pointerRefs = pointers - .mapKeys { it.key.timestamp } - .filterKeys { it != null } - .mapKeys { it.key!! } - - val messageTimestamp = message.id.uuid?.timestamp - - val status = findClosestMessageStatus( - timestamp = messageTimestamp, - statusMap = pointerRefs, - fallback = if (contents.isFromSelf) MessageStatus.Sent else MessageStatus.Unknown - ) - - ChatItem.Message( - chatMessageId = message.id, - message = contents, - date = message.dateMillis.toInstantFromMillis(), - status = status, - isFromSelf = contents.isFromSelf, - key = contents.hashCode() + message.id.hashCode() - ) - } - } - - override fun onCleared() { - super.onCleared() - conversationController.closeChatStream() - } - - internal companion object { - val updateStateForEvent: (Event) -> ((State) -> State) = { event -> - Timber.d("event=${event}") - when (event) { - is Event.OnConversationChanged -> { state -> - val (conversation, _) = event.conversationWithPointers - val members = conversation.nonSelfMembers - - state.copy( - conversationId = conversation.id, - identityRevealed = conversation.hasRevealedIdentity, - pointers = event.conversationWithPointers.pointers, - twitterUser = null, - users = members.map { - State.User( - memberId = it.id, - username = it.identity?.username, - imageUrl = null, - ) - } - ) - } - - is Event.OnTipsChatCashChanged -> { state -> - state.copy( - tipChatCash = event.module - ) - } - - is Event.OnCostToChatChanged -> { state -> - state.copy(costToChat = event.cost) - } - - is Event.OnMembersChanged -> { state -> - state.copy(users = event.members) - } - - is Event.OnPointersUpdated -> { state -> - state.copy(pointers = event.pointers) - } - - is Event.OnIdentityAvailable -> { state -> - state.copy(identityAvailable = event.available) - } - - is Event.OnTwitterUserChanged -> { state -> - state.copy(twitterUser = event.user) - } - - is Event.OnTypingStarted -> { state -> - state.copy(showTypingIndicator = true) - } - is Event.OnTypingStopped -> { state -> - state.copy(showTypingIndicator = false) - } - - is Event.OnUserTypingStarted -> { state -> - state.copy(isSelfTyping = true) - } - is Event.OnUserTypingStopped -> { state -> - state.copy(isSelfTyping = false) - } - - is Event.PresentPaymentConfirmation, - is Event.OnChatIdChanged, - is Event.Error, - Event.RevealIdentity, - Event.SendCash, - is Event.MarkRead, - is Event.MarkDelivered, - is Event.SendMessage -> { state -> state } - - is Event.OnIdentityRevealed -> { state -> - state.copy(identityRevealed = true) - } - - is Event.OnUserRevealed -> { state -> - val users = state.users - val updatedUsers = users.map { - if (it.memberId == event.memberId) { - it.copy( - username = event.username ?: it.username, - imageUrl = event.imageUrl ?: it.imageUrl, - ) - } else { - it - } - } - - state.copy(users = updatedUsers) - } - - is Event.OnUserActivity -> { state -> - state.copy(lastSeen = event.activity) - } - } - } - } -} - -private fun findClosestMessageStatus( - timestamp: Long?, - statusMap: Map, - fallback: MessageStatus -): MessageStatus { - timestamp ?: return fallback - var closestKey: Long? = null - - for (key in statusMap.keys) { - if (timestamp <= key && (closestKey == null || key <= closestKey)) { - closestKey = key - } - } - - return closestKey?.let { statusMap[it] } ?: fallback -} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/chat/create/byusername/ChatByUsernameScreen.kt b/app/src/main/java/com/getcode/view/main/chat/create/byusername/ChatByUsernameScreen.kt deleted file mode 100644 index 693f474a6..000000000 --- a/app/src/main/java/com/getcode/view/main/chat/create/byusername/ChatByUsernameScreen.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.getcode.view.main.chat.create.byusername - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.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.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.getcode.R -import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.ConversationScreen -import com.getcode.theme.CodeTheme -import com.getcode.theme.inputColors -import com.getcode.ui.components.ConstraintMode -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton -import com.getcode.ui.components.CodeScaffold -import com.getcode.ui.components.TextInput -import com.getcode.ui.components.keyboardAsState -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ChatByUsernameScreen( - viewModel: ChatByUsernameViewModel -) { - val state by viewModel.stateFlow.collectAsState() - val navigator = LocalCodeNavigator.current - - val keyboardVisible by keyboardAsState() - val keyboardController = LocalSoftwareKeyboardController.current - val composeScope = rememberCoroutineScope() - var isChecking by remember(state.checkingUsername) { mutableStateOf(false) } - - val checkUsername = { - composeScope.launch { - isChecking = true - if (keyboardVisible) { - keyboardController?.hide() - delay(500) - } - viewModel.dispatchEvent(ChatByUsernameViewModel.Event.CheckUsername) - } - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { it.user } - .onEach { - navigator.push(ConversationScreen(user = it)) - }.launchIn(this) - } - - CodeScaffold( - modifier = Modifier.fillMaxSize().imePadding(), - bottomBar = { - Box(modifier = Modifier.fillMaxWidth()) { - CodeButton( - enabled = state.canAdvance, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset) - .padding(bottom = CodeTheme.dimens.grid.x2), - buttonState = ButtonState.Filled, - text = stringResource(R.string.action_next), - isLoading = isChecking, - isSuccess = state.isValidUsername - ) { - checkUsername() - } - } - } - ) { padding -> - val focusRequester = remember { FocusRequester() } - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) - ) { - TextInput( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center) - .padding(CodeTheme.dimens.inset) - .focusRequester(focusRequester), - state = state.textFieldState, - colors = inputColors( - backgroundColor = Color.Transparent, - borderColor = Color.Transparent - ), - maxLines = 1, - constraintMode = ConstraintMode.AutoSize(minimum = CodeTheme.typography.displaySmall), - contentPadding = PaddingValues(horizontal = 20.dp), - style = CodeTheme.typography.displayMedium, - placeholderStyle = CodeTheme.typography.displayMedium, - placeholder = stringResource(R.string.subtitle_xUsername), - ) - } - - LaunchedEffect(focusRequester) { - focusRequester.requestFocus() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/chat/create/byusername/ChatByUsernameViewModel.kt b/app/src/main/java/com/getcode/view/main/chat/create/byusername/ChatByUsernameViewModel.kt deleted file mode 100644 index 1dc263c38..000000000 --- a/app/src/main/java/com/getcode/view/main/chat/create/byusername/ChatByUsernameViewModel.kt +++ /dev/null @@ -1,95 +0,0 @@ -@file:OptIn(ExperimentalFoundationApi::class) - -package com.getcode.view.main.chat.create.byusername - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.text2.input.TextFieldState -import androidx.compose.foundation.text2.input.clearText -import androidx.lifecycle.viewModelScope -import com.getcode.R -import com.getcode.manager.TopBarManager -import com.getcode.model.TwitterUser -import com.getcode.network.TipController -import com.getcode.network.TwitterUserController -import com.getcode.util.resources.ResourceHelper -import com.getcode.view.BaseViewModel2 -import com.getcode.view.main.chat.conversation.ConversationViewModel.Event -import com.getcode.view.main.chat.conversation.ConversationViewModel.State -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import javax.inject.Inject - -@HiltViewModel -class ChatByUsernameViewModel @Inject constructor( - resources: ResourceHelper, - twitterUserController: TwitterUserController, -): BaseViewModel2( - initialState = State(), - updateStateForEvent = updateStateForEvent -) { - data class State( - val checkingUsername: Boolean = false, - val isValidUsername: Boolean = false, - val textFieldState: TextFieldState = TextFieldState(), - ) { - val canAdvance: Boolean - get() = textFieldState.text.isNotEmpty() - } - - sealed interface Event { - data object CheckUsername : Event - data class OnSuccess(val user: TwitterUser) : Event - data object OnError : Event - } - - init { - eventFlow - .filterIsInstance() - .map { stateFlow.value } - .map { - val textFieldState = it.textFieldState - val text = textFieldState.text.toString() - - runCatching { twitterUserController.fetchUser(text) } - } - .map { it.getOrNull() } - .onEach { twitterUser -> - if (twitterUser == null) { - dispatchEvent(Event.OnError) - } else { - dispatchEvent(Event.OnSuccess(twitterUser)) - } - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { - TopBarManager.showMessage( - TopBarManager.TopBarMessage( - title = resources.getString(R.string.error_title_usernameNotFound), - message = resources.getString(R.string.error_description_usernameNotFound) - ) - ) - }.launchIn(viewModelScope) - } - - internal companion object { - val updateStateForEvent: (Event) -> ((State) -> State) = { event -> - when (event) { - Event.CheckUsername -> { state -> - state.copy(checkingUsername = true) - } - Event.OnError -> { state -> - state.copy(checkingUsername = false) - } - is Event.OnSuccess -> { state -> - state.copy(checkingUsername = false, isValidUsername = true) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/chat/list/ChatListScreen.kt b/app/src/main/java/com/getcode/view/main/chat/list/ChatListScreen.kt deleted file mode 100644 index 9e722d56c..000000000 --- a/app/src/main/java/com/getcode/view/main/chat/list/ChatListScreen.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.getcode.view.main.chat.list - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.Divider -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment.Companion.CenterHorizontally -import androidx.compose.ui.Alignment.Companion.CenterVertically -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import com.getcode.R -import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.ChatByUsernameScreen -import com.getcode.navigation.screens.ConversationScreen -import com.getcode.theme.CodeTheme -import com.getcode.theme.White10 -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton -import com.getcode.ui.components.CodeCircularProgressIndicator -import com.getcode.ui.components.CodeScaffold -import com.getcode.ui.components.chat.ChatNode - -@Composable -fun ChatListScreen( - viewModel: ChatListViewModel, -) { - val state by viewModel.stateFlow.collectAsState() - val navigator = LocalCodeNavigator.current - - val chatsEmpty by remember(state.conversations) { - derivedStateOf { state.conversations.isEmpty() } - } - - CodeScaffold( - bottomBar = { - Box(modifier = Modifier.fillMaxWidth()) { - CodeButton( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset), - buttonState = ButtonState.Filled, - text = stringResource(R.string.action_startNewChat) - ) { - navigator.push(ChatByUsernameScreen) - } - } - } - ) { padding -> - LazyColumn(modifier = Modifier.padding(padding)) { - items(state.conversations, key = { it.id }) { chat -> - ChatNode(chat = chat, showAvatar = true) { - navigator.push(ConversationScreen(chatId = chat.id)) - } - Divider( - modifier = Modifier.padding(start = CodeTheme.dimens.inset), - color = White10, - ) - } - - when { - state.loading -> { - item { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = CenterHorizontally, - verticalArrangement = Arrangement.spacedBy( - CodeTheme.dimens.grid.x2, - CenterVertically - ), - ) { - CodeCircularProgressIndicator() - Text( - modifier = Modifier.fillMaxWidth(0.6f), - text = stringResource(R.string.subtitle_loadingChats), - textAlign = TextAlign.Center - ) - } - } - } - chatsEmpty -> { - item { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = CenterHorizontally, - verticalArrangement = Arrangement.spacedBy( - CodeTheme.dimens.grid.x2, - CenterVertically - ), - ) { - Text( - modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x1), - text = stringResource(R.string.subtitle_dontHaveChats), - color = CodeTheme.colors.textSecondary, - style = CodeTheme.typography.textMedium - ) - } - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/chat/list/ChatListViewModel.kt b/app/src/main/java/com/getcode/view/main/chat/list/ChatListViewModel.kt deleted file mode 100644 index f2f236378..000000000 --- a/app/src/main/java/com/getcode/view/main/chat/list/ChatListViewModel.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.getcode.view.main.chat.list - -import androidx.lifecycle.viewModelScope -import com.getcode.model.chat.ConversationEntity -import com.getcode.network.ConversationListController -import com.getcode.utils.network.NetworkConnectivityListener -import com.getcode.view.BaseViewModel2 -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import javax.inject.Inject - -@HiltViewModel -class ChatListViewModel @Inject constructor( - conversationsController: ConversationListController, - networkObserver: NetworkConnectivityListener, -): BaseViewModel2( - initialState = State(), - updateStateForEvent = updateStateForEvent -) { - data class State( - val loading: Boolean = false, - val conversations: List = emptyList(), - ) - - sealed interface Event { - data class OnChatsLoading(val loading: Boolean) : Event - data class OnChatsUpdated(val chats: List) : Event - data object OnOpened: Event - } - - init { - conversationsController.observeConversations() - .onEach { - if (it == null || (it.isEmpty() && !networkObserver.isConnected)) { - dispatchEvent(Dispatchers.Main, Event.OnChatsLoading(true)) - } - } - .map { conversations -> - when { - conversations == null -> null // await for confirmation it's empty - conversations.isEmpty() && !networkObserver.isConnected -> null // remain loading while disconnected - conversationsController.isLoading -> null // remain loading while fetching messages - else -> conversations - } - } - .filterNotNull() - .onEach { update -> - dispatchEvent(Dispatchers.Main, Event.OnChatsUpdated(update)) - }.onEach { - dispatchEvent(Dispatchers.Main, Event.OnChatsLoading(false)) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { conversationsController.fetchChats() } - .launchIn(viewModelScope) - } - - companion object { - val updateStateForEvent: (Event) -> ((State) -> State) = { event -> - when (event) { - is Event.OnOpened -> { state -> state } - is Event.OnChatsLoading -> { state -> state.copy(loading = event.loading) } - is Event.OnChatsUpdated -> { state -> state.copy(conversations = event.chats) } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/connectivity/ConnectionStatusProvider.kt b/app/src/main/java/com/getcode/view/main/connectivity/ConnectionStatusProvider.kt deleted file mode 100644 index 1e261f554..000000000 --- a/app/src/main/java/com/getcode/view/main/connectivity/ConnectionStatusProvider.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.getcode.view.main.connectivity - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import com.getcode.utils.network.ConnectionType -import com.getcode.utils.network.NetworkState -import com.getcode.utils.network.SignalStrength - -internal class NetworkStateProvider ( - override val values: Sequence = sequenceOf( - NetworkState(connected = false, type = ConnectionType.Unknown, signalStrength = SignalStrength.Unknown), - NetworkState(connected = false, type = ConnectionType.Wifi, signalStrength = SignalStrength.Great), - NetworkState(connected = true, type = ConnectionType.Wifi, signalStrength = SignalStrength.Great), - ) -): PreviewParameterProvider \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/currency/CurrencySelectionSheet.kt b/app/src/main/java/com/getcode/view/main/currency/CurrencySelectionSheet.kt index 949068af3..106e8595f 100644 --- a/app/src/main/java/com/getcode/view/main/currency/CurrencySelectionSheet.kt +++ b/app/src/main/java/com/getcode/view/main/currency/CurrencySelectionSheet.kt @@ -57,10 +57,9 @@ import com.getcode.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.Brand import com.getcode.theme.CodeTheme -import com.getcode.theme.White05 import com.getcode.theme.White50 import com.getcode.theme.inputColors -import com.getcode.ui.components.CodeCircularProgressIndicator +import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.utils.keyboardAsState import com.getcode.ui.utils.rememberedClickable import com.getcode.view.main.giveKin.CurrencyListItem @@ -331,7 +330,7 @@ private fun GroupHeader(modifier: Modifier = Modifier, text: String) { ) } Divider( - color = White05, + color = CodeTheme.colors.dividerVariant, modifier = Modifier .fillMaxWidth() .height(1.dp) @@ -433,7 +432,7 @@ private fun ListRowItem( } Divider( - color = White05, + color = CodeTheme.colors.dividerVariant, modifier = Modifier .fillMaxWidth() .height(1.dp) diff --git a/app/src/main/java/com/getcode/view/main/currency/CurrencyViewModel.kt b/app/src/main/java/com/getcode/view/main/currency/CurrencyViewModel.kt index 5b4427a24..f10e0798d 100644 --- a/app/src/main/java/com/getcode/view/main/currency/CurrencyViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/currency/CurrencyViewModel.kt @@ -4,12 +4,10 @@ import androidx.compose.runtime.Stable import androidx.lifecycle.viewModelScope import com.getcode.R import com.getcode.model.Currency -import com.getcode.model.PrefString -import com.getcode.model.PrefsBool -import com.getcode.model.PrefsString +import com.getcode.services.model.PrefsBool +import com.getcode.services.model.PrefsString import com.getcode.network.exchange.Exchange import com.getcode.network.repository.PrefRepository -import com.getcode.util.CurrencyUtils import com.getcode.util.locale.LocaleHelper import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel2 @@ -28,7 +26,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart -import kotlinx.serialization.Serializable import javax.inject.Inject enum class CurrencySelectKind { @@ -45,7 +42,7 @@ enum class CurrencySelectKind { @HiltViewModel class CurrencyViewModel @Inject constructor( localeHelper: LocaleHelper, - currencyUtils: CurrencyUtils, + currencyUtils: com.getcode.utils.CurrencyUtils, exchange: Exchange, private val prefsRepository: PrefRepository, private val resources: ResourceHelper, diff --git a/app/src/main/java/com/getcode/view/main/getKin/BuyAndSellKin.kt b/app/src/main/java/com/getcode/view/main/getKin/BuyAndSellKin.kt index 7ef34e2b7..5df497b2c 100644 --- a/app/src/main/java/com/getcode/view/main/getKin/BuyAndSellKin.kt +++ b/app/src/main/java/com/getcode/view/main/getKin/BuyAndSellKin.kt @@ -29,8 +29,8 @@ import com.getcode.R import com.getcode.theme.CodeTheme import com.getcode.theme.bolded import com.getcode.ui.utils.rememberedClickable -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn diff --git a/app/src/main/java/com/getcode/view/main/getKin/BuyKinScreen.kt b/app/src/main/java/com/getcode/view/main/getKin/BuyKinScreen.kt index c7b6409b9..a1c6aca26 100644 --- a/app/src/main/java/com/getcode/view/main/getKin/BuyKinScreen.kt +++ b/app/src/main/java/com/getcode/view/main/getKin/BuyKinScreen.kt @@ -20,18 +20,18 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import com.getcode.LocalBetaFlags -import com.getcode.LocalNetworkObserver import com.getcode.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.KadoWebScreen import com.getcode.theme.CodeTheme -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton -import com.getcode.ui.components.CodeKeyPad +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeKeyPad import com.getcode.ui.components.Row import com.getcode.util.showNetworkError import com.getcode.utils.ErrorUtils -import com.getcode.view.main.giveKin.AmountArea +import com.getcode.utils.network.LocalNetworkObserver +import com.getcode.ui.components.text.AmountArea import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.seconds diff --git a/app/src/main/java/com/getcode/view/main/getKin/BuyKinViewModel.kt b/app/src/main/java/com/getcode/view/main/getKin/BuyKinViewModel.kt index 31697c0e6..06f64dfdc 100644 --- a/app/src/main/java/com/getcode/view/main/getKin/BuyKinViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/getKin/BuyKinViewModel.kt @@ -12,6 +12,7 @@ import com.getcode.model.CurrencyCode import com.getcode.model.Fiat import com.getcode.model.KinAmount import com.getcode.model.Rate +import com.getcode.model.fromFiatAmount import com.getcode.network.client.Client import com.getcode.network.client.declareFiatPurchase import com.getcode.network.client.linkAdditionalAccount @@ -20,15 +21,12 @@ import com.getcode.network.repository.BalanceRepository import com.getcode.network.repository.PhoneRepository import com.getcode.network.repository.PrefRepository import com.getcode.network.repository.TransactionRepository +import com.getcode.services.utils.makeE164 import com.getcode.solana.organizer.AccountType -import com.getcode.util.CurrencyUtils -import com.getcode.util.locale.LocaleHelper import com.getcode.util.resources.ResourceHelper import com.getcode.utils.FormatUtils import com.getcode.utils.blockchainMemo -import com.getcode.utils.makeE164 -import com.getcode.utils.network.NetworkConnectivityListener -import com.getcode.view.main.giveKin.AmountAnimatedInputUiModel +import com.getcode.ui.components.text.AmountAnimatedInputUiModel import com.getcode.view.main.giveKin.AmountUiModel import com.getcode.view.main.giveKin.BaseAmountCurrencyViewModel import com.getcode.view.main.giveKin.CurrencyUiModel @@ -52,9 +50,9 @@ class BuyKinViewModel @Inject constructor( prefsRepository: PrefRepository, balanceRepository: BalanceRepository, transactionRepository: TransactionRepository, - localeHelper: LocaleHelper, - private val currencyUtils: CurrencyUtils, - private val networkObserver: NetworkConnectivityListener, + localeHelper: com.getcode.util.locale.LocaleHelper, + private val currencyUtils: com.getcode.utils.CurrencyUtils, + private val networkObserver: com.getcode.utils.network.NetworkConnectivityListener, resources: ResourceHelper, private val phoneRepository: PhoneRepository, ) : BaseAmountCurrencyViewModel( diff --git a/app/src/main/java/com/getcode/view/main/getKin/GetKinSheet.kt b/app/src/main/java/com/getcode/view/main/getKin/GetKinSheet.kt index 6954fbe7e..198e95a1e 100644 --- a/app/src/main/java/com/getcode/view/main/getKin/GetKinSheet.kt +++ b/app/src/main/java/com/getcode/view/main/getKin/GetKinSheet.kt @@ -52,8 +52,8 @@ import com.getcode.theme.Success import com.getcode.theme.White import com.getcode.theme.White05 import com.getcode.theme.bolded -import com.getcode.ui.components.CodeCircularProgressIndicator -import com.getcode.ui.components.CodeScaffold +import com.getcode.ui.theme.CodeCircularProgressIndicator +import com.getcode.ui.theme.CodeScaffold import com.getcode.ui.components.showSnackbar import com.getcode.ui.utils.addIf import com.getcode.ui.utils.rememberedClickable @@ -263,7 +263,7 @@ private fun GetKinItemRow(modifier: Modifier = Modifier, item: GetKinItem) { ) { Text( text = item.titleText, - color = if (item.isActive) Color.White else BrandLight, + color = if (item.isActive) Color.White else CodeTheme.colors.brandLight, style = CodeTheme.typography.textSmall.copy( textDecoration = if (item.isStrikeThrough) TextDecoration.LineThrough else null, ), diff --git a/app/src/main/java/com/getcode/view/main/getKin/KadoWebScreen.kt b/app/src/main/java/com/getcode/view/main/getKin/KadoWebScreen.kt index 725d4d971..7bbfbe684 100644 --- a/app/src/main/java/com/getcode/view/main/getKin/KadoWebScreen.kt +++ b/app/src/main/java/com/getcode/view/main/getKin/KadoWebScreen.kt @@ -18,7 +18,7 @@ import androidx.compose.ui.graphics.Color import com.getcode.manager.TopBarManager import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.KadoWebScreen.BuyKinWebInterface -import com.getcode.ui.components.CodeCircularProgressIndicator +import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.utils.toAGColor import com.kevinnzou.web.AccompanistWebViewClient import com.kevinnzou.web.LoadingState @@ -45,7 +45,6 @@ fun BoxScope.KadoWebScreen( object : AccompanistWebViewClient() { override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) - println("url=$url") if (url?.startsWith("https://app.kado.money/ramp/order/") == true) { // order created, extract order id orderId = Uri.parse(url).lastPathSegment.orEmpty() diff --git a/app/src/main/java/com/getcode/view/main/giveKin/BaseAmountCurrencyViewModel.kt b/app/src/main/java/com/getcode/view/main/giveKin/BaseAmountCurrencyViewModel.kt index fe6a8ce01..0f54cdf44 100644 --- a/app/src/main/java/com/getcode/view/main/giveKin/BaseAmountCurrencyViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/giveKin/BaseAmountCurrencyViewModel.kt @@ -11,14 +11,13 @@ import com.getcode.network.exchange.Exchange import com.getcode.network.repository.BalanceRepository import com.getcode.network.repository.PrefRepository import com.getcode.network.repository.TransactionRepository -import com.getcode.network.repository.replaceParam -import com.getcode.util.CurrencyUtils -import com.getcode.util.Kin -import com.getcode.util.NumberInputHelper -import com.getcode.util.locale.LocaleHelper +import com.getcode.ui.components.text.AmountAnimatedInputUiModel +import com.getcode.ui.components.text.AmountInputViewModel +import com.getcode.utils.Kin +import com.getcode.ui.components.text.NumberInputHelper import com.getcode.util.resources.ResourceHelper import com.getcode.utils.FormatUtils -import com.getcode.utils.network.NetworkConnectivityListener +import com.getcode.utils.replaceParam import com.getcode.view.BaseViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -88,10 +87,10 @@ abstract class BaseAmountCurrencyViewModel( protected val exchange: Exchange, private val balanceRepository: BalanceRepository, private val transactionRepository: TransactionRepository, - protected val localeHelper: LocaleHelper, - private val currencyUtils: CurrencyUtils, + protected val localeHelper: com.getcode.util.locale.LocaleHelper, + private val currencyUtils: com.getcode.utils.CurrencyUtils, protected val resources: ResourceHelper, - private val networkObserver: NetworkConnectivityListener, + private val networkObserver: com.getcode.utils.network.NetworkConnectivityListener, ) : BaseViewModel(resources), AmountInputViewModel { protected val numberInputHelper = NumberInputHelper() abstract fun setCurrencyUiModel(currencyUiModel: CurrencyUiModel) diff --git a/app/src/main/java/com/getcode/view/main/giveKin/GiveKinScreen.kt b/app/src/main/java/com/getcode/view/main/giveKin/GiveKinScreen.kt index c17591a16..291c4cf38 100644 --- a/app/src/main/java/com/getcode/view/main/giveKin/GiveKinScreen.kt +++ b/app/src/main/java/com/getcode/view/main/giveKin/GiveKinScreen.kt @@ -18,20 +18,19 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import cafe.adriel.voyager.navigator.currentOrThrow -import com.getcode.LocalNetworkObserver import com.getcode.LocalSession import com.getcode.R import com.getcode.models.Bill import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.CurrencySelectionModal -import com.getcode.theme.Alert -import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton -import com.getcode.ui.components.CodeKeyPad +import com.getcode.ui.components.text.AmountArea +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeKeyPad import com.getcode.util.showNetworkError import com.getcode.utils.ErrorUtils +import com.getcode.utils.network.LocalNetworkObserver import kotlinx.coroutines.launch @Preview @@ -62,7 +61,7 @@ fun GiveKinScreen( } val color = - if (isInError) Alert else BrandLight + if (isInError) CodeTheme.colors.errorText else CodeTheme.colors.brandLight Box( modifier = Modifier.weight(0.65f) ) { diff --git a/app/src/main/java/com/getcode/view/main/giveKin/GiveKinSheetViewModel.kt b/app/src/main/java/com/getcode/view/main/giveKin/GiveKinSheetViewModel.kt index dbb722ef1..2f35b8e2c 100644 --- a/app/src/main/java/com/getcode/view/main/giveKin/GiveKinSheetViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/giveKin/GiveKinSheetViewModel.kt @@ -5,18 +5,17 @@ import com.getcode.R import com.getcode.manager.TopBarManager import com.getcode.model.CurrencyCode import com.getcode.model.KinAmount +import com.getcode.model.fromFiatAmount import com.getcode.network.client.Client import com.getcode.network.client.receiveIfNeeded import com.getcode.network.exchange.Exchange import com.getcode.network.repository.BalanceRepository import com.getcode.network.repository.PrefRepository import com.getcode.network.repository.TransactionRepository -import com.getcode.network.repository.replaceParam -import com.getcode.util.CurrencyUtils -import com.getcode.util.locale.LocaleHelper +import com.getcode.ui.components.text.AmountAnimatedInputUiModel import com.getcode.util.resources.ResourceHelper import com.getcode.utils.ErrorUtils -import com.getcode.utils.network.NetworkConnectivityListener +import com.getcode.utils.replaceParam import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -39,9 +38,9 @@ class GiveKinSheetViewModel @Inject constructor( prefsRepository: PrefRepository, balanceRepository: BalanceRepository, transactionRepository: TransactionRepository, - localeHelper: LocaleHelper, - currencyUtils: CurrencyUtils, - networkObserver: NetworkConnectivityListener, + localeHelper: com.getcode.util.locale.LocaleHelper, + currencyUtils: com.getcode.utils.CurrencyUtils, + networkObserver: com.getcode.utils.network.NetworkConnectivityListener, resources: ResourceHelper, ) : BaseAmountCurrencyViewModel( client, diff --git a/app/src/main/java/com/getcode/view/main/requestKin/RequestKinScreen.kt b/app/src/main/java/com/getcode/view/main/requestKin/RequestKinScreen.kt index cf33c0f25..a8a68d22b 100644 --- a/app/src/main/java/com/getcode/view/main/requestKin/RequestKinScreen.kt +++ b/app/src/main/java/com/getcode/view/main/requestKin/RequestKinScreen.kt @@ -16,20 +16,18 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import cafe.adriel.voyager.navigator.currentOrThrow -import com.getcode.LocalNetworkObserver import com.getcode.LocalSession import com.getcode.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.CurrencySelectionModal -import com.getcode.theme.Alert -import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton -import com.getcode.ui.components.CodeKeyPad +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeKeyPad import com.getcode.util.showNetworkError import com.getcode.utils.ErrorUtils -import com.getcode.view.main.giveKin.AmountArea +import com.getcode.utils.network.LocalNetworkObserver +import com.getcode.ui.components.text.AmountArea import kotlinx.coroutines.launch @Preview @@ -53,7 +51,7 @@ fun RequestKinScreen( horizontalAlignment = Alignment.CenterHorizontally ) { val color = - if (dataState.amountModel.amountKin > dataState.amountModel.buyLimitKin) Alert else BrandLight + if (dataState.amountModel.amountKin > dataState.amountModel.buyLimitKin) CodeTheme.colors.errorText else CodeTheme.colors.brandLight Box( modifier = Modifier.weight(0.65f) ) { diff --git a/app/src/main/java/com/getcode/view/main/requestKin/RequestKinViewModel.kt b/app/src/main/java/com/getcode/view/main/requestKin/RequestKinViewModel.kt index 8d3ab8ead..9ddd984e6 100644 --- a/app/src/main/java/com/getcode/view/main/requestKin/RequestKinViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/requestKin/RequestKinViewModel.kt @@ -3,18 +3,16 @@ package com.getcode.view.main.requestKin import androidx.lifecycle.viewModelScope import com.getcode.model.CurrencyCode import com.getcode.model.KinAmount +import com.getcode.model.fromFiatAmount import com.getcode.network.client.Client import com.getcode.network.client.receiveIfNeeded import com.getcode.network.exchange.Exchange import com.getcode.network.repository.BalanceRepository import com.getcode.network.repository.PrefRepository import com.getcode.network.repository.TransactionRepository -import com.getcode.util.CurrencyUtils -import com.getcode.util.locale.LocaleHelper import com.getcode.util.resources.ResourceHelper import com.getcode.utils.ErrorUtils -import com.getcode.utils.network.NetworkConnectivityListener -import com.getcode.view.main.giveKin.AmountAnimatedInputUiModel +import com.getcode.ui.components.text.AmountAnimatedInputUiModel import com.getcode.view.main.giveKin.AmountUiModel import com.getcode.view.main.giveKin.BaseAmountCurrencyViewModel import com.getcode.view.main.giveKin.CurrencyUiModel @@ -33,9 +31,9 @@ class RequestKinViewModel @Inject constructor( prefsRepository: PrefRepository, balanceRepository: BalanceRepository, transactionRepository: TransactionRepository, - localeHelper: LocaleHelper, - currencyUtils: CurrencyUtils, - networkObserver: NetworkConnectivityListener, + localeHelper: com.getcode.util.locale.LocaleHelper, + currencyUtils: com.getcode.utils.CurrencyUtils, + networkObserver: com.getcode.utils.network.NetworkConnectivityListener, resources: ResourceHelper, ) : BaseAmountCurrencyViewModel( client, diff --git a/app/src/main/java/com/getcode/view/main/scanner/DecorView.kt b/app/src/main/java/com/getcode/view/main/scanner/DecorView.kt index f03519ab4..179ac583c 100644 --- a/app/src/main/java/com/getcode/view/main/scanner/DecorView.kt +++ b/app/src/main/java/com/getcode/view/main/scanner/DecorView.kt @@ -40,13 +40,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.getcode.SessionState -import com.getcode.LocalNetworkObserver import com.getcode.R import com.getcode.theme.CodeTheme import com.getcode.theme.xxl import com.getcode.ui.components.Pill import com.getcode.ui.tips.DefinedTips import com.getcode.ui.utils.unboundedClickable +import com.getcode.utils.network.LocalNetworkObserver import com.getcode.view.main.scanner.components.HomeBottom import dev.bmcreations.tipkit.LocalTipProvider import dev.bmcreations.tipkit.engines.LocalTipsEngine diff --git a/app/src/main/java/com/getcode/view/main/scanner/ScannerScreen.kt b/app/src/main/java/com/getcode/view/main/scanner/ScannerScreen.kt index ee03a0244..ba6d7e409 100644 --- a/app/src/main/java/com/getcode/view/main/scanner/ScannerScreen.kt +++ b/app/src/main/java/com/getcode/view/main/scanner/ScannerScreen.kt @@ -46,7 +46,6 @@ import com.getcode.SessionController import com.getcode.LocalBiometricsState import com.getcode.PresentationStyle import com.getcode.R -import com.getcode.RestrictionType import com.getcode.manager.TopBarManager import com.getcode.models.Bill import com.getcode.models.DeepLinkRequest @@ -54,27 +53,26 @@ import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.AccountModal import com.getcode.navigation.screens.BalanceModal -import com.getcode.navigation.screens.ChatListModal import com.getcode.navigation.screens.ConnectAccount import com.getcode.navigation.screens.EnterTipModal import com.getcode.navigation.screens.GetKinModal import com.getcode.navigation.screens.GiveKinModal import com.getcode.navigation.screens.ShareDownloadLinkModal import com.getcode.ui.components.OnLifecycleEvent -import com.getcode.ui.components.PermissionResult -import com.getcode.ui.components.getPermissionLauncher -import com.getcode.ui.components.rememberPermissionChecker import com.getcode.ui.utils.AnimationUtils import com.getcode.ui.utils.measured import com.getcode.util.launchAppSettings -import com.getcode.view.login.notificationPermissionCheck import com.getcode.view.main.bill.BillManagementOptions import com.getcode.view.main.scanner.views.CameraDisabledView import com.getcode.view.main.scanner.camera.CodeScanner import com.getcode.view.main.bill.HomeBill import com.getcode.view.main.scanner.views.CameraPermissionsMissingView import com.getcode.ui.modals.ReceivedKinConfirmation -import com.getcode.view.main.scanner.views.HomeRestricted +import com.getcode.util.permissions.PermissionResult +import com.getcode.util.permissions.getPermissionLauncher +import com.getcode.util.permissions.rememberPermissionHandler +import com.getcode.ui.components.restrictions.ContentRestrictedView +import com.getcode.ui.components.restrictions.RestrictionType import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -91,7 +89,6 @@ enum class UiElement { BALANCE, SHARE_DOWNLOAD, TIP_CARD, - CHAT, GALLERY } @@ -108,7 +105,7 @@ fun ScanScreen( RestrictionType.ACCESS_EXPIRED, RestrictionType.FORCE_UPGRADE, RestrictionType.TIMELOCK_UNLOCKED -> { - HomeRestricted(restrictionType) { + ContentRestrictedView(restrictionType) { session.logout(it) } } @@ -121,7 +118,8 @@ fun ScanScreen( request = request, ) - val notificationPermissionChecker = notificationPermissionCheck { } + val notificationPermissionChecker = + com.getcode.util.permissions.notificationPermissionCheck { } val context = LocalContext.current LaunchedEffect(session) { session.eventFlow @@ -231,7 +229,6 @@ private fun ScannerContent( } } - UiElement.CHAT -> navigator.show(ChatListModal) UiElement.GALLERY -> { pickPhoto.launch( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) @@ -348,7 +345,7 @@ private fun BillContainer( val cameraPermissionLauncher = getPermissionLauncher(Manifest.permission.CAMERA, onPermissionResult) - val permissionChecker = rememberPermissionChecker() + val permissionChecker = rememberPermissionHandler() val checkPermission = { shouldRequest: Boolean -> permissionChecker.request( diff --git a/app/src/main/java/com/getcode/view/main/scanner/components/BottomBar.kt b/app/src/main/java/com/getcode/view/main/scanner/components/BottomBar.kt index a3bdb8359..393a3b0d3 100644 --- a/app/src/main/java/com/getcode/view/main/scanner/components/BottomBar.kt +++ b/app/src/main/java/com/getcode/view/main/scanner/components/BottomBar.kt @@ -29,7 +29,6 @@ import com.getcode.R import com.getcode.theme.CodeTheme import com.getcode.ui.components.Badge import com.getcode.ui.components.Row -import com.getcode.ui.components.chat.ChatNodeDefaults import com.getcode.ui.utils.heightOrZero import com.getcode.ui.utils.unboundedClickable import com.getcode.ui.utils.widthOrZero @@ -92,16 +91,6 @@ internal fun HomeBottom( ) } - UiElement.CHAT -> { - BottomBarAction( - modifier = Modifier.weight(1f), - label = stringResource(R.string.action_chat), - painter = painterResource(R.drawable.ic_chat), - badgeCount = state.chatUnreadCount, - onClick = { onPress(action) }, - ) - } - else -> { BottomBarAction( modifier = Modifier @@ -140,7 +129,7 @@ private fun BottomBarAction( Badge( modifier = Modifier.padding(top = 6.dp, end = 1.dp), count = badgeCount, - color = ChatNodeDefaults.UnreadIndicator, + color = CodeTheme.colors.indicator, enterTransition = scaleIn( animationSpec = tween( durationMillis = 300, diff --git a/app/src/main/java/com/getcode/view/main/scanner/views/BiometricsBlockingView.kt b/app/src/main/java/com/getcode/view/main/scanner/views/BiometricsBlockingView.kt index fce802afd..4157fa67a 100644 --- a/app/src/main/java/com/getcode/view/main/scanner/views/BiometricsBlockingView.kt +++ b/app/src/main/java/com/getcode/view/main/scanner/views/BiometricsBlockingView.kt @@ -14,8 +14,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.getcode.R import com.getcode.theme.Brand -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton import com.getcode.ui.utils.BiometricsState @Composable diff --git a/app/src/main/java/com/getcode/view/main/scanner/views/CameraDisabledView.kt b/app/src/main/java/com/getcode/view/main/scanner/views/CameraDisabledView.kt index d22fcccd3..4cdf6aabf 100644 --- a/app/src/main/java/com/getcode/view/main/scanner/views/CameraDisabledView.kt +++ b/app/src/main/java/com/getcode/view/main/scanner/views/CameraDisabledView.kt @@ -11,14 +11,13 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.getcode.R import com.getcode.theme.CodeTheme -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton @Composable internal fun CameraDisabledView( diff --git a/app/src/main/java/com/getcode/view/main/scanner/views/CameraPermissionsMissingView.kt b/app/src/main/java/com/getcode/view/main/scanner/views/CameraPermissionsMissingView.kt index 394a2f130..6112fecdc 100644 --- a/app/src/main/java/com/getcode/view/main/scanner/views/CameraPermissionsMissingView.kt +++ b/app/src/main/java/com/getcode/view/main/scanner/views/CameraPermissionsMissingView.kt @@ -17,8 +17,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.getcode.R import com.getcode.theme.CodeTheme -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton @Composable internal fun CameraPermissionsMissingView( diff --git a/app/src/main/java/com/getcode/view/main/tip/ConnectAccountScreen.kt b/app/src/main/java/com/getcode/view/main/tip/ConnectAccountScreen.kt index 58b51c862..72951ee0e 100644 --- a/app/src/main/java/com/getcode/view/main/tip/ConnectAccountScreen.kt +++ b/app/src/main/java/com/getcode/view/main/tip/ConnectAccountScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -29,6 +28,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import com.getcode.R @@ -36,12 +36,13 @@ import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.Brand import com.getcode.theme.BrandSubtle import com.getcode.theme.CodeTheme +import com.getcode.theme.DesignSystem import com.getcode.theme.bolded import com.getcode.theme.extraSmall -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton -import com.getcode.ui.components.CodeScaffold import com.getcode.ui.components.Row +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn @@ -50,11 +51,13 @@ import kotlinx.coroutines.flow.onEach enum class IdentityConnectionReason { TipCard, IdentityReveal, + Login, } @Composable fun ConnectAccountScreen( - viewModel: TipConnectViewModel = hiltViewModel() + viewModel: ConnectAccountViewModel = hiltViewModel(), + titleAlignment: TextAlign = TextAlign.Start, ) { val state by viewModel.stateFlow.collectAsState() val navigator = LocalCodeNavigator.current @@ -62,11 +65,12 @@ fun ConnectAccountScreen( LaunchedEffect(viewModel) { viewModel.eventFlow - .filterIsInstance() + .filterIsInstance() .onEach { composeTweet(context, it.intent) delay(1_000) when (state.reason) { + IdentityConnectionReason.Login, IdentityConnectionReason.IdentityReveal -> navigator.pop() else -> navigator.hide() } @@ -80,7 +84,7 @@ fun ConnectAccountScreen( modifier = Modifier.fillMaxWidth() .padding(CodeTheme.dimens.inset), onClick = { - viewModel.dispatchEvent(TipConnectViewModel.Event.PostToX) + viewModel.dispatchEvent(ConnectAccountViewModel.Event.PostToX) }, buttonState = ButtonState.Filled, content = { @@ -103,29 +107,35 @@ fun ConnectAccountScreen( .padding(padding), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset) ) { - RequestContent(state = state) + RequestContent(state = state, titleAlignment = titleAlignment) } } } @Composable -private fun ColumnScope.RequestContent(state: TipConnectViewModel.State) { +private fun ColumnScope.RequestContent(state: ConnectAccountViewModel.State, titleAlignment: TextAlign = TextAlign.Start) { Text( + modifier = Modifier.fillMaxWidth(), text = when(state.reason) { IdentityConnectionReason.TipCard -> stringResource(id = R.string.title_receiveTips) IdentityConnectionReason.IdentityReveal -> stringResource(id = R.string.title_connectAccount) + IdentityConnectionReason.Login -> stringResource(id = R.string.title_connectYourX) null -> "" }, - style = CodeTheme.typography.displayMedium.bolded() + style = CodeTheme.typography.displayMedium.bolded(), + textAlign = titleAlignment, ) Text( + modifier = Modifier.fillMaxWidth(), text = when(state.reason) { IdentityConnectionReason.TipCard -> stringResource(id = R.string.subtitle_tipCardXDescription) IdentityConnectionReason.IdentityReveal -> stringResource(id = R.string.subtitle_connectXAccount) + IdentityConnectionReason.Login -> stringResource(id = R.string.subtitle_identityInApp, stringResource(R.string.app_name_without_variant)) null -> "" }, - style = CodeTheme.typography.textSmall + style = CodeTheme.typography.textSmall, + textAlign = titleAlignment, ) Spacer(modifier = Modifier.weight(0.3f)) TweetPreview(modifier = Modifier.fillMaxWidth(), xMessage = state.xMessage) @@ -167,7 +177,7 @@ private fun composeTweet(context: Context, intent: Intent) { @Preview @Composable private fun Preview_TweetPreview() { - CodeTheme { + DesignSystem { TweetPreview(xMessage = "${stringResource(R.string.subtitle_connectXTweetText)}\n" + "\n" + "CodeAccount:349pQtzGmiBxU9vADVf6AUdMLLXyCCU3Zu4smrQPXved:zGmiBxU9vADVf6AUdMLLXyCCU3Zu4smrQP") diff --git a/app/src/main/java/com/getcode/view/main/tip/TipConnectViewModel.kt b/app/src/main/java/com/getcode/view/main/tip/ConnectAccountViewModel.kt similarity index 73% rename from app/src/main/java/com/getcode/view/main/tip/TipConnectViewModel.kt rename to app/src/main/java/com/getcode/view/main/tip/ConnectAccountViewModel.kt index 9704ac1cf..857d87a74 100644 --- a/app/src/main/java/com/getcode/view/main/tip/TipConnectViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/tip/ConnectAccountViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.getcode.R import com.getcode.analytics.Action import com.getcode.analytics.AnalyticsService +import com.getcode.network.IdentityManager import com.getcode.network.TipController import com.getcode.util.IntentUtils import com.getcode.util.resources.ResourceHelper @@ -18,11 +19,12 @@ import kotlinx.coroutines.flow.onEach import javax.inject.Inject @HiltViewModel -class TipConnectViewModel @Inject constructor( +class ConnectAccountViewModel @Inject constructor( resources: ResourceHelper, tipController: TipController, analytics: AnalyticsService, -) : BaseViewModel2( + identityManager: IdentityManager, +) : BaseViewModel2( initialState = State(null, ""), updateStateForEvent = updateStateForEvent ) { @@ -44,9 +46,10 @@ class TipConnectViewModel @Inject constructor( .filterIsInstance() .map { it.reason } .mapNotNull { - val verificationMessage = tipController.generateTipVerification() ?: return@mapNotNull null when (it) { IdentityConnectionReason.TipCard -> { + val verificationMessage = identityManager.generateVerificationTweet(resources.getString(R.string.account_name_link)) ?: return@mapNotNull null + """ ${resources.getString(R.string.subtitle_connectXTweetText)} @@ -55,12 +58,23 @@ class TipConnectViewModel @Inject constructor( } IdentityConnectionReason.IdentityReveal -> { + val verificationMessage = identityManager.generateVerificationTweet(resources.getString(R.string.account_name_link)) ?: return@mapNotNull null """ ${resources.getString(R.string.subtitle_linkingTwitterToRevealIdentity)} $verificationMessage """.trimIndent() } + + IdentityConnectionReason.Login -> { + val verificationMessage = identityManager.generateVerificationTweet(resources.getString(R.string.account_name_link)) ?: return@mapNotNull null + + """ + ${resources.getString(R.string.subtitle_linkingTwitterToLogin, resources.getString(R.string.handle))} + + $verificationMessage + """.trimIndent() + } } }.onEach { dispatchEvent(Event.UpdateMessage(it)) diff --git a/app/src/main/java/com/getcode/view/main/tip/EnterTipScreen.kt b/app/src/main/java/com/getcode/view/main/tip/EnterTipScreen.kt index d8983ab28..a9b695216 100644 --- a/app/src/main/java/com/getcode/view/main/tip/EnterTipScreen.kt +++ b/app/src/main/java/com/getcode/view/main/tip/EnterTipScreen.kt @@ -17,20 +17,19 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import cafe.adriel.voyager.navigator.currentOrThrow -import com.getcode.LocalNetworkObserver import com.getcode.LocalSession import com.getcode.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.CurrencySelectionModal import com.getcode.theme.Alert -import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton -import com.getcode.ui.components.CodeKeyPad +import com.getcode.ui.components.text.AmountArea +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeKeyPad import com.getcode.util.showNetworkError import com.getcode.utils.ErrorUtils -import com.getcode.view.main.giveKin.AmountArea +import com.getcode.utils.network.LocalNetworkObserver import kotlinx.coroutines.launch @Composable @@ -61,7 +60,7 @@ fun EnterTipScreen( } } - val color = if (isInError) Alert else BrandLight + val color = if (isInError) Alert else CodeTheme.colors.textSecondary Box( modifier = Modifier.weight(0.65f) ) { diff --git a/app/src/main/java/com/getcode/view/main/tip/TipPaymentViewModel.kt b/app/src/main/java/com/getcode/view/main/tip/TipPaymentViewModel.kt index 155a368e1..f45bc1e32 100644 --- a/app/src/main/java/com/getcode/view/main/tip/TipPaymentViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/tip/TipPaymentViewModel.kt @@ -2,26 +2,24 @@ package com.getcode.view.main.tip import androidx.lifecycle.viewModelScope import com.getcode.R -import com.getcode.manager.TopBarManager import com.getcode.model.CurrencyCode import com.getcode.model.Kin import com.getcode.model.KinAmount import com.getcode.model.Rate import com.getcode.model.SendLimit +import com.getcode.model.fromFiatAmount import com.getcode.network.client.Client import com.getcode.network.client.receiveIfNeeded import com.getcode.network.exchange.Exchange import com.getcode.network.repository.BalanceRepository import com.getcode.network.repository.PrefRepository import com.getcode.network.repository.TransactionRepository -import com.getcode.util.CurrencyUtils -import com.getcode.util.formattedRaw -import com.getcode.util.locale.LocaleHelper +import com.getcode.extensions.formattedRaw +import com.getcode.manager.TopBarManager import com.getcode.util.resources.ResourceHelper import com.getcode.utils.ErrorUtils import com.getcode.utils.FormatUtils -import com.getcode.utils.network.NetworkConnectivityListener -import com.getcode.view.main.giveKin.AmountAnimatedInputUiModel +import com.getcode.ui.components.text.AmountAnimatedInputUiModel import com.getcode.view.main.giveKin.AmountUiModel import com.getcode.view.main.giveKin.BaseAmountCurrencyViewModel import com.getcode.view.main.giveKin.CurrencyUiModel @@ -40,9 +38,9 @@ class TipPaymentViewModel @Inject constructor( prefsRepository: PrefRepository, balanceRepository: BalanceRepository, private val transactionRepository: TransactionRepository, - localeHelper: LocaleHelper, - currencyUtils: CurrencyUtils, - networkObserver: NetworkConnectivityListener, + localeHelper: com.getcode.util.locale.LocaleHelper, + currencyUtils: com.getcode.utils.CurrencyUtils, + networkObserver: com.getcode.utils.network.NetworkConnectivityListener, resources: ResourceHelper, ) : BaseAmountCurrencyViewModel( client, diff --git a/app/src/main/java/com/kik/kikx/kikcodes/implementation/KikCodeAnalyzer.kt b/app/src/main/java/com/kik/kikx/kikcodes/implementation/KikCodeAnalyzer.kt index 7d2be0f6c..2120e57ac 100644 --- a/app/src/main/java/com/kik/kikx/kikcodes/implementation/KikCodeAnalyzer.kt +++ b/app/src/main/java/com/kik/kikx/kikcodes/implementation/KikCodeAnalyzer.kt @@ -1,6 +1,5 @@ package com.kik.kikx.kikcodes.implementation -import android.content.Context import android.net.Uri import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy @@ -12,7 +11,6 @@ import com.getcode.utils.ErrorUtils import com.kik.kikx.kikcodes.KikCodeScanner import com.kik.kikx.kikcodes.ScannerError import com.kik.kikx.models.ScannableKikCode -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/app/src/main/res/drawable-nodpi/ic_notification_request.png b/app/src/main/res/drawable/ic_notification_request.png similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_notification_request.png rename to app/src/main/res/drawable/ic_notification_request.png diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index bbd3e0212..7353dbd1f 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index bbd3e0212..7353dbd1f 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index aa1162753..1d56a948f 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -19,6 +19,7 @@ #0F0C1F #7379A0 #565C86 + #443091 #0F0C1F #8785A9 diff --git a/app/src/main/res/values/strings-universal.xml b/app/src/main/res/values/strings-universal.xml index 060be710b..c3bf55070 100644 --- a/app/src/main/res/values/strings-universal.xml +++ b/app/src/main/res/values/strings-universal.xml @@ -1,6 +1,8 @@ - Code + https://app.getcode.com/tos + https://app.getcode.com/privacy-policy + https://getcode.com/d https://getcode.com/d?r=%1$s aqr diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1073dd58a..db4f53c10 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,10 +1,11 @@ + com.getcode.accountprovider Nevermind - This payment request could not be paid at this time. Please try again later. This login request could not be completed at this time. Please try again later. Failed to create a USDC deposit account. Payment Failed + This payment request could not be paid at this time. Please try again later. Login Failed Account Error You can only request up to %1$s @@ -24,12 +25,6 @@ Message Messaged - You revealed your identity to %1$s - %1$s revealed their identity to you - This person tipped you %1$s - 🙏 You thanked them - 🙏 %1$s thanked you for your tip - Send Kin Require Biometrics @@ -62,4 +57,17 @@ Loading your chats Start a New Chat You don\'t have any chats yet. + + Connect Your X + Identity in %1$s is based on your X identity. + %1$s I’d like to connect my X + Message %1$s to Connect + Cash + Chats + Settings + + Code + Code + \@getcode + CodeAccount diff --git a/app/src/release/AndroidManifest.xml b/app/src/release/AndroidManifest.xml new file mode 100644 index 000000000..16c292d86 --- /dev/null +++ b/app/src/release/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 96e44c946..89f3c5583 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,6 +34,10 @@ allprojects { configurations.all { exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib-jdk7") } + +// tasks.matching { it.name.contains("kapt") }.configureEach { +// enabled = false +// } } tasks.register("clean", Delete::class) { diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index e9bc91c69..9b88f8f6d 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -1,7 +1,10 @@ @file:Suppress("ConstPropertyName") + object Android { - const val namespace = "com.getcode" + const val codeNamespace = "com.getcode" + const val flipchatNamespace = "xyz.flipchat" + const val compileSdkVersion = 34 const val minSdkVersion = 24 const val targetSdkVersion = 34 @@ -9,23 +12,36 @@ object Android { const val buildToolsVersion = "34.0.0" } -object Packaging { - private const val majorVersion = 2 - private const val minorVersion = 1 - private const val patchVersion = 14 - - const val versionName = "$majorVersion.$minorVersion.$patchVersion" +sealed class Packaging( + majorVersion: Int, + minorVersion: Int, + patchVersion: Int, +) { + val versionName = "$majorVersion.$minorVersion.$patchVersion" + + object Code: Packaging( + majorVersion = 2, + minorVersion = 1, + patchVersion = 14, + ) + + object Flipchat: Packaging( + majorVersion = 1, + minorVersion = 0, + patchVersion = 6, + ) } object Versions { const val java = "17" - const val kotlin = "1.9.23" + const val kotlin = "1.9.25" const val kotlinx_coroutines = "1.7.3" const val kotlinx_serialization = "1.6.2" const val kotlinx_datetime = "0.5.0" - const val android_gradle_build_tools = "8.4.0" + const val android_gradle_build_tools = "8.6.0" const val google_services = "4.3.15" + const val androidx_appcompat = "1.7.0" const val androidx_activity = "1.7.2" const val androidx_annotation = "1.7.1" const val androidx_biometrics = "1.2.0-alpha05" @@ -39,20 +55,20 @@ object Versions { const val androidx_room = "2.6.1" const val sqlcipher = "4.5.1@aar" - const val compose = "2024.05.00" + const val compose = "2024.10.01" // compose compiler is tied to [Versions.kotlin] // See compatibility mapping here: // https://developer.android.com/jetpack/androidx/releases/compose-compiler - const val compose_compiler = "1.5.11" + const val compose_compiler = "1.5.15" const val compose_activities: String = "1.8.2" const val compose_view_models: String = "2.6.2" const val compose_navigation: String = "2.7.3" const val compose_paging = "3.3.0-alpha02" const val compose_webview = "0.33.6" - const val hilt = "2.50" - const val hilt_jetpack = "1.1.0-beta01" + const val hilt = "2.52" + const val hilt_jetpack = "1.2.0" const val okhttp = "4.9.3" const val retrofit = "2.6.0" const val rxjava: String = "3.1.3" @@ -67,6 +83,7 @@ object Versions { const val crashlytics_gradle: String = "2.8.1" const val play_service_auth = "20.7.0" const val play_service_auth_phone = "18.0.2" + const val google_play_billing = "7.1.1" const val grpc: String = "1.62.2" const val grpc_okhttp: String = "1.33.1" @@ -76,7 +93,6 @@ object Versions { const val mp_android_chart: String = "v3.1.0" const val lib_phone_number_port: String = "8.12.43" const val lib_phone_number_google: String = "8.12.54" - const val hilt_nav_compose: String = "1.1.0-alpha02" const val zxing: String = "3.3.2" const val androidx_test_runner = "1.4.0" @@ -87,7 +103,7 @@ object Versions { const val markwon = "4.6.2" const val timber = "5.0.1" - const val voyager = "1.0.0" + const val voyager = "1.1.0-beta02" const val protobuf_plugin = "0.9.4" const val sodium_bindings = "0.9.0" @@ -137,6 +153,7 @@ object Plugins { object Libs { const val android_desugaring = "com.android.tools:desugar_jdk_libs:${Versions.desugaring}" + const val androidx_appcompat = "androidx.appcompat:appcompat:${Versions.androidx_appcompat}" const val androidx_activity = "androidx.activity:activity-ktx:{${Versions.androidx_activity}" const val androidx_annotation = "androidx.annotation:annotation:${Versions.androidx_annotation}" const val androidx_biometrics = "androidx.biometric:biometric:${Versions.androidx_biometrics}" @@ -211,6 +228,7 @@ object Libs { "androidx.compose.ui:ui-tooling-preview" const val compose_foundation = "androidx.compose.foundation:foundation" const val compose_material = "androidx.compose.material:material" + const val compose_materialIconsCore = "androidx.compose.material:material-icons-core" const val compose_materialIconsExtended = "androidx.compose.material:material-icons-extended-android" const val compose_activities = @@ -227,6 +245,8 @@ object Libs { "cafe.adriel.voyager:voyager-hilt:${Versions.voyager}" const val compose_voyager_navigation_bottomsheet = "cafe.adriel.voyager:voyager-bottom-sheet-navigator:${Versions.voyager}" + const val compose_voyager_navigation_tabs = + "cafe.adriel.voyager:voyager-tab-navigator:${Versions.voyager}" const val compose_voyager_navigation_transitions = "cafe.adriel.voyager:voyager-transitions:${Versions.voyager}" const val compose_webview = "io.github.kevinnzou:compose-webview:${Versions.compose_webview}" @@ -251,6 +271,9 @@ object Libs { const val play_service_auth_phone = "com.google.android.gms:play-services-auth-api-phone:${Versions.play_service_auth_phone}" + const val google_play_billing_runtime = "com.android.billingclient:billing:${Versions.google_play_billing}" + const val google_play_billing_ktx = "com.android.billingclient:billing-ktx:${Versions.google_play_billing}" + const val grpc_okhttp = "io.grpc:grpc-okhttp:${Versions.grpc_okhttp}" const val grpc_kotlin = "io.grpc:grpc-kotlin-stub:${Versions.grpc_kotlin}" const val grpc_protobuf = "io.grpc:grpc-protobuf:${Versions.grpc}" @@ -266,7 +289,7 @@ object Libs { "io.michaelrocks:libphonenumber-android:${Versions.lib_phone_number_port}" const val lib_phone_number_google = "com.googlecode.libphonenumber:libphonenumber:${Versions.lib_phone_number_google}" - const val hilt_nav_compose = "androidx.hilt:hilt-navigation-compose:1.1.0-alpha01" + const val hilt_nav_compose = "androidx.hilt:hilt-navigation-compose:${Versions.hilt_jetpack}" const val zxing = "com.google.zxing:core:${Versions.zxing}" const val retrofit = "com.squareup.retrofit2:retrofit:${Versions.retrofit}" @@ -298,5 +321,6 @@ object Libs { const val fingerprint_pro = "com.fingerprint.android:pro:2.4.0" const val haze = "dev.chrisbanes.haze:haze:0.7.3" - const val process_phoenix = "com.jakewharton:process-phoenix:3.0.0" + const val rinku = "dev.theolm:rinku:1.1.0" + const val rinku_compose = "dev.theolm:rinku-compose-ext:1.1.0" } diff --git a/crypto/ed25519/src/main/AndroidManifest.xml b/crypto/ed25519/src/main/AndroidManifest.xml deleted file mode 100644 index 29188a871..000000000 --- a/crypto/ed25519/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/crypto/kin/.gitignore b/definitions/code-vm/models/.gitignore similarity index 100% rename from crypto/kin/.gitignore rename to definitions/code-vm/models/.gitignore diff --git a/definitions/code-vm/models/build.gradle.kts b/definitions/code-vm/models/build.gradle.kts new file mode 100644 index 000000000..1c4157544 --- /dev/null +++ b/definitions/code-vm/models/build.gradle.kts @@ -0,0 +1,82 @@ +import org.apache.tools.ant.taskdefs.condition.Os + +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id("com.google.protobuf") +} + +val archSuffix = if (Os.isFamily(Os.FAMILY_MAC)) ":osx-x86_64" else "" + +version = "0.0.1" +group = "com.codeinc.gen" + +dependencies { + protobuf(project(":definitions:code-vm:protos")) + + implementation(Libs.grpc_protobuf_lite) + implementation(Libs.grpc_stub) + + // Kotlin Generation + implementation(Libs.grpc_kotlin) + implementation(Libs.protobuf_kotlin_lite) + implementation(Libs.kotlinx_coroutines_core) +} + +kotlin { + jvmToolchain(17) +} + +android { + namespace = "${Android.codeNamespace}.service.models" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } +} + +tasks.withType().all { + kotlinOptions { + freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn") + } +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${Versions.protobuf}$archSuffix" + } + plugins { + create("java") { + artifact = "io.grpc:protoc-gen-grpc-java:${Versions.grpc}" + } + create("grpc") { + artifact = "io.grpc:protoc-gen-grpc-java:${Versions.grpc}" + } + create("grpckt") { + artifact = "io.grpc:protoc-gen-grpc-kotlin:1.4.1:jdk8@jar" + } + } + generateProtoTasks { + all().forEach { + it.plugins { + create("java") { + option("lite") + } + create("grpc") { + option("lite") + } + create("grpckt") { + option("lite") + } + } + it.builtins { + create("kotlin") { + option("lite") + } + } + } + } +} \ No newline at end of file diff --git a/service/models/.gitignore b/definitions/code-vm/protos/.gitignore similarity index 100% rename from service/models/.gitignore rename to definitions/code-vm/protos/.gitignore diff --git a/service/protos/build.gradle.kts b/definitions/code-vm/protos/build.gradle.kts similarity index 100% rename from service/protos/build.gradle.kts rename to definitions/code-vm/protos/build.gradle.kts diff --git a/definitions/code-vm/protos/src/main/proto/account/v1/code_account_service.proto b/definitions/code-vm/protos/src/main/proto/account/v1/code_account_service.proto new file mode 100644 index 000000000..d36ed1db8 --- /dev/null +++ b/definitions/code-vm/protos/src/main/proto/account/v1/code_account_service.proto @@ -0,0 +1,239 @@ +syntax = "proto3"; + +package code.account.v1; + +option go_package = "github.com/code-payments/code-protobuf-api/generated/go/account/v1;account"; +option java_package = "com.codeinc.gen.account.v1"; +option objc_class_prefix = "CPBAccountV1"; + +import "common/v1/code_model.proto"; +import "transaction/v2/code_transaction_service.proto"; +import "google/protobuf/timestamp.proto"; + + +service Account { + // IsCodeAccount returns whether an owner account is a Code account. This hints + // to the client whether the account can be logged in, used for making payments, + // etc. + rpc IsCodeAccount(IsCodeAccountRequest) returns (IsCodeAccountResponse); + + // GetTokenAccountInfos returns token account metadata relevant to the Code owner + // account. + rpc GetTokenAccountInfos(GetTokenAccountInfosRequest) returns (GetTokenAccountInfosResponse); + + // LinkAdditionalAccounts allows a client to declare additional accounts to + // be tracked and used within Code. The accounts declared in this RPC are not + // managed by Code (ie. not a Timelock account), created externally and cannot + // be linked automatically (ie. authority derived off user 12 words). + rpc LinkAdditionalAccounts(LinkAdditionalAccountsRequest) returns (LinkAdditionalAccountsResponse); +} + +message IsCodeAccountRequest { + // The owner account to check against. + common.v1.SolanaAccountId owner = 1; + + + // The signature is of serialize(IsCodeAccountRequest) without this field set + // using the private key of the owner account. This provides an authentication + // mechanism to the RPC. + common.v1.Signature signature = 2; + +} + +message IsCodeAccountResponse { + Result result = 1; + enum Result { + // The account is a Code account. + OK = 0; + // The account is not a Code account. + NOT_FOUND = 1; + // The account exists, but at least one timelock account is unlocked. + UNLOCKED_TIMELOCK_ACCOUNT = 2; + } +} + +message GetTokenAccountInfosRequest { + // The owner account, which can also be thought of as a parent account for this + // RPC that links to one or more token accounts. + common.v1.SolanaAccountId owner = 1; + + + // The signature is of serialize(GetTokenAccountInfosRequest) without this field set + // using the private key of the owner account. This provides an authentication + // mechanism to the RPC. + common.v1.Signature signature = 2; + +} + +message GetTokenAccountInfosResponse { + Result result = 1; + enum Result { + OK = 0; + NOT_FOUND = 1; + } + + map token_account_infos = 2; +} + +message LinkAdditionalAccountsRequest { + // The owner account to link to + common.v1.SolanaAccountId owner = 1; + + + // The authority account derived off the user's 12 words, which contains + // the USDC ATA (and potentially others in the future) that will be used + // in swaps. + common.v1.SolanaAccountId swap_authority = 2; + + + // Signature values for each account provided in this request. Each signature + // must be generated without this array set. The expected ordering of signatures: + // 1. owner + // 2. swap_authority + repeated common.v1.Signature signatures = 3 ; + +} + +message LinkAdditionalAccountsResponse { + Result result = 1; + enum Result { + // Supports idempotency, and will be returned as long as the request exactly + // matches a previous execution. + OK = 0; + // The action has been denied (eg. owner account not phone verified) + DENIED = 1; + // An account being linked is not valid + INVALID_ACCOUNT = 2; + } +} + +message TokenAccountInfo { + // The token account's address + common.v1.SolanaAccountId address = 1; + + + // The owner of the token account, which can also be thought of as a parent + // account that links to one or more token accounts. This is provided when + // available. + common.v1.SolanaAccountId owner = 2; + + // The token account's authority, which has access to moving funds for the + // account. This can be the owner account under certain circumstances (eg. + // ATA, primary account). This is provided when available. + common.v1.SolanaAccountId authority = 3; + + // The type of token account, which infers its intended use. + common.v1.AccountType account_type = 4; + + + // The account's derivation index for applicable account types. When this field + // doesn't apply, a zero value is provided. + uint64 index = 5; + + // The source of truth for the balance calculation. + BalanceSource balance_source = 6; + enum BalanceSource { + // The account's balance could not be determined. This may be returned when + // the data source is unstable and a reliable balance cannot be determined. + BALANCE_SOURCE_UNKNOWN = 0; + // The account's balance was fetched directly from a finalized state on the + // blockchain. + BALANCE_SOURCE_BLOCKCHAIN = 1; + // The account's balance was calculated using cached values in Code. Accuracy + // is only guaranteed when management_state is LOCKED. + BALANCE_SOURCE_CACHE = 2; + } + + // The balance in quarks, as observed by Code. This may not reflect the value + // on the blockchain and could be non-zero even if the account hasn't been created. + // Use balance_source to determine how this value was calculated. + uint64 balance = 7; + + // The state of the account as it pertains to Code's ability to manage funds. + ManagementState management_state = 8; + enum ManagementState { + // The state of the account is unknown. This may be returned when the + // data source is unstable and a reliable state cannot be determined. + MANAGEMENT_STATE_UNKNOWN = 0; + // Code does not maintain a management state and won't move funds for this + // account. + MANAGEMENT_STATE_NONE = 1; + // The account is in the process of transitioning to the LOCKED state. + MANAGEMENT_STATE_LOCKING = 2; + // The account's funds are locked and Code has co-signing authority. + MANAGEMENT_STATE_LOCKED = 3; + // The account is in the process of transitioning to the UNLOCKED state. + MANAGEMENT_STATE_UNLOCKING = 4; + // The account's funds are unlocked and Code no longer has co-signing + // authority. The account must transition to the LOCKED state to have + // management capabilities. + MANAGEMENT_STATE_UNLOCKED = 5; + // The account is in the process of transitioning to the CLOSED state. + MANAGEMENT_STATE_CLOSING = 6; + // The account has been closed and doesn't exist on the blockchain. + // Subsequently, it also has a zero balance. + MANAGEMENT_STATE_CLOSED = 7; + } + + // The state of the account on the blockchain. + BlockchainState blockchain_state = 9; + enum BlockchainState { + // The state of the account is unknown. This may be returned when the + // data source is unstable and a reliable state cannot be determined. + BLOCKCHAIN_STATE_UNKNOWN = 0; + // The account does not exist on the blockchain. + BLOCKCHAIN_STATE_DOES_NOT_EXIST = 1; + // The account is created and exists on the blockchain. + BLOCKCHAIN_STATE_EXISTS = 2; + } + + // For temporary incoming accounts only. Flag indicates whether client must + // actively try rotating it by issuing a ReceivePaymentsPrivately intent. In + // general, clients should wait as long as possible until this flag is true + // or requiring the funds to send their next payment. + bool must_rotate = 10; + + // Whether an account is claimed. This only applies to relevant account types + // (eg. REMOTE_SEND_GIFT_CARD). + ClaimState claim_state = 11; + enum ClaimState { + // The account doesn't have a concept of being claimed, or the state + // could not be fetched by server. + CLAIM_STATE_UNKNOWN = 0; + // The account has not yet been claimed. + CLAIM_STATE_NOT_CLAIMED = 1; + // The account is claimed. Attempting to claim it will fail. + CLAIM_STATE_CLAIMED = 2; + // The account hasn't been claimed, but is expired. Funds will move + // back to the issuer. Attempting to claim it will fail. + CLAIM_STATE_EXPIRED = 3; + } + + // For account types used as an intermediary for sending money between two + // users (eg. REMOTE_SEND_GIFT_CARD), this represents the original exchange + // data used to fund the account. Over time, this value will become stale: + // 1. Exchange rates will fluctuate, so the total fiat amount will differ. + // 2. External entities can deposit additional funds into the account, so + // the balance, in quarks, may be greater than the original quark value. + // 3. The balance could have been received, so the total balance can show + // as zero. + transaction.v2.ExchangeData original_exchange_data = 12; + + // The token account's mint + common.v1.SolanaAccountId mint = 13; + + // Reserved for the number of decimals configured for the mint + reserved 14; + + // Reserved for a user-friendly display name for the mint + reserved 15; + + // The relationship with a third party that this account has established with. + // This only applies to relevant account types (eg. RELATIONSHIP). + common.v1.Relationship relationship = 16; + + // Time the account was created, if available. For Code accounts, this is + // the time of intent submission. Otherwise, for external accounts, it is + // the tiem created on the blockchain. + google.protobuf.Timestamp created_at = 17; +} diff --git a/definitions/code-vm/protos/src/main/proto/badge/v1/code_badge_service.proto b/definitions/code-vm/protos/src/main/proto/badge/v1/code_badge_service.proto new file mode 100644 index 000000000..f26d20ce4 --- /dev/null +++ b/definitions/code-vm/protos/src/main/proto/badge/v1/code_badge_service.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; +package code.badge.v1; +option go_package = "github.com/code-payments/code-protobuf-api/generated/go/badge/v1;badge"; +option java_package = "com.codeinc.gen.badge.v1"; +option objc_class_prefix = "CPBBadgeV1"; +import "common/v1/code_model.proto"; + +service Badge { + // ResetBadgeCount resets an owner account's app icon badge count back to zero + rpc ResetBadgeCount(ResetBadgeCountRequest) returns (ResetBadgeCountResponse); +} +message ResetBadgeCountRequest { + // The owner account to clear badge count + common.v1.SolanaAccountId owner = 1; + // The signature is of serialize(ResetBadgeCountRequest) without this field set + // using the private key of the owner account. This provides an authentication + // mechanism to the RPC. + common.v1.Signature signature = 2; +} +message ResetBadgeCountResponse { + Result result = 1; + enum Result { + OK = 0; + } +} diff --git a/definitions/code-vm/protos/src/main/proto/chat/v1/code_chat_service.proto b/definitions/code-vm/protos/src/main/proto/chat/v1/code_chat_service.proto new file mode 100644 index 000000000..51c4a40bc --- /dev/null +++ b/definitions/code-vm/protos/src/main/proto/chat/v1/code_chat_service.proto @@ -0,0 +1,256 @@ +syntax = "proto3"; +package code.chat.v1; +option go_package = "github.com/code-payments/code-protobuf-api/generated/go/chat/v1;chat"; +option java_package = "com.codeinc.gen.chat.v1"; +option objc_class_prefix = "CPBChatV1"; +import "common/v1/code_model.proto"; +import "transaction/v2/code_transaction_service.proto"; +import "google/protobuf/timestamp.proto"; + +// Deprecated: Use the v2 service +service Chat { + // GetChats gets the set of chats for an owner account + rpc GetChats(GetChatsRequest) returns (GetChatsResponse); + // GetMessages gets the set of messages for a chat + rpc GetMessages(GetMessagesRequest) returns (GetMessagesResponse); + // AdvancePointer advances a pointer in chat history + rpc AdvancePointer(AdvancePointerRequest) returns (AdvancePointerResponse); + // SetMuteState configures the mute state of a chat + rpc SetMuteState(SetMuteStateRequest) returns (SetMuteStateResponse); + // SetSubscriptionState configures the susbscription state of a chat + rpc SetSubscriptionState(SetSubscriptionStateRequest) returns (SetSubscriptionStateResponse); + // + // Experimental PoC two-way chat APIs below + // + rpc StreamChatEvents(stream StreamChatEventsRequest) returns (stream StreamChatEventsResponse); + rpc SendMessage(SendMessageRequest) returns (SendMessageResponse); +} +message GetChatsRequest { + common.v1.SolanaAccountId owner = 1; + common.v1.Signature signature = 2; + uint32 page_size = 3; + Cursor cursor = 4; + Direction direction = 5; + enum Direction { + ASC = 0; + DESC = 1; + } +} +message GetChatsResponse { + Result result = 1; + enum Result { + OK = 0; + NOT_FOUND = 1; + } + repeated ChatMetadata chats = 2 ; +} +message GetMessagesRequest { + ChatId chat_id = 1; + common.v1.SolanaAccountId owner = 2; + common.v1.Signature signature = 3; + uint32 page_size = 4; + Cursor cursor = 5; + Direction direction = 6; + enum Direction { + ASC = 0; + DESC = 1; + } +} +message GetMessagesResponse { + Result result = 1; + enum Result { + OK = 0; + NOT_FOUND = 1; + } + repeated ChatMessage messages = 2 ; +} +message AdvancePointerRequest { + ChatId chat_id = 1; + Pointer pointer = 2; + common.v1.SolanaAccountId owner = 3; + common.v1.Signature signature = 4; +} +message AdvancePointerResponse { + Result result = 1; + enum Result { + OK = 0; + CHAT_NOT_FOUND = 1; + MESSAGE_NOT_FOUND = 2; + } +} +message SetMuteStateRequest { + ChatId chat_id = 1; + bool is_muted = 2; + common.v1.SolanaAccountId owner = 3; + common.v1.Signature signature = 4; +} +message SetMuteStateResponse { + Result result = 1; + enum Result { + OK = 0; + CHAT_NOT_FOUND = 1; + CANT_MUTE = 2; + } +} +message SetSubscriptionStateRequest { + ChatId chat_id = 1; + bool is_subscribed = 2; + common.v1.SolanaAccountId owner = 3; + common.v1.Signature signature = 4; +} +message SetSubscriptionStateResponse { + Result result = 1; + enum Result { + OK = 0; + CHAT_NOT_FOUND = 1; + CANT_UNSUBSCRIBE = 2; + } +} +message OpenChatEventStream { + ChatId chat_id = 1; + common.v1.SolanaAccountId owner = 2; + common.v1.Signature signature = 3; +} +message ChatStreamEvent { + repeated ChatMessage messages = 1; + repeated Pointer pointers = 2; +} +message ChatStreamEventBatch { + repeated ChatStreamEvent events = 2 ; +} +message StreamChatEventsRequest { + oneof type { + OpenChatEventStream open_stream = 1; + common.v1.ClientPong pong = 2; + } +} +message StreamChatEventsResponse { + oneof type { + ChatStreamEventBatch events = 1; + common.v1.ServerPing ping = 2; + } +} +message SendMessageRequest { + common.v1.SolanaAccountId owner = 1; + common.v1.Signature signature = 2; + ChatId chat_id = 3; + // todo: What field type should this be? Maybe the chat message itself with fields missing? + repeated Content content = 4 ; +} +message SendMessageResponse { + Result result = 1; + enum Result { + OK = 0; + } + ChatMessage message = 2; +} +message ChatId { + bytes value = 1 ; +} +message ChatMessageId { + bytes value = 1 ; +} +message ChatMemberId { + // todo: Public key for now + bytes value = 1 ; +} +message Pointer { + Kind kind = 1; + enum Kind { + UNKNOWN = 0; + READ = 1; + DELIVERED = 2; + SENT = 3; // Probably always inferred by OK result in SendMessageResponse + } + ChatMessageId value = 2; + ChatMemberId user = 3; +} +message ChatMetadata { + ChatId chat_id = 1; + // Recommended chat title inferred by the type of chat + oneof title { + ServerLocalizedContent localized = 2; + common.v1.Domain domain = 3; + } + // Pointer in the chat indicating the most recently read message by the user + Pointer read_pointer = 4; + // Estimated number of unread messages in this chat + uint32 num_unread = 5; + // Has the user muted this chat? + bool is_muted = 6; + // Is the user subscribed to this chat? + bool is_subscribed = 7; + // Can the user mute this chat? + bool can_mute = 8; + // Can the user unsubscribe from this chat? + bool can_unsubscribe = 9; + // Cursor value for this chat for reference in subsequent GetChatsRequest + Cursor cursor = 10; + // Is this a verified chat? + // + // Note: It's possible to have two chats with the same title, but with + // different verification statuses. They should be treated separately. + bool is_verified = 11; +} +message ChatMessage { + // Unique ID for this message + ChatMessageId message_id = 1; + // Timestamp this message was generated at + google.protobuf.Timestamp ts = 2; + // Ordered message content. A message may have more than one piece of content. + repeated Content content = 3 ; + // Cursor value for this message for reference in subsequent GetMessagesRequest + Cursor cursor = 4; + ChatMemberId sender = 5; +} +message Content { + oneof type { + ServerLocalizedContent server_localized = 1; + ExchangeDataContent exchange_data = 2; + NaclBoxEncryptedContent nacl_box = 3; + TextContent text = 4; + ThankYouContent thank_you = 5; + } +} +message ServerLocalizedContent { + // When server-side localization is in place, clients will always see the + // localized text. + string key_or_text = 1 ; +} +message ExchangeDataContent { + Verb verb = 1; + enum Verb { + UNKNOWN = 0; + GAVE = 1; + RECEIVED = 2; + WITHDREW = 3; + DEPOSITED = 4; + SENT = 5; + RETURNED = 6; + SPENT = 7; + PAID = 8; + PURCHASED = 9; + RECEIVED_TIP = 10; + SENT_TIP = 11; + } + oneof exchange_data { + transaction.v2.ExchangeData exact = 2; + transaction.v2.ExchangeDataWithoutRate partial = 3; + } +} +message NaclBoxEncryptedContent { + common.v1.SolanaAccountId peer_public_key = 1; + bytes nonce = 2 ; + bytes encrypted_payload = 3 ; +} +message TextContent { + string text = 1 ; +} +message ThankYouContent { + // todo: May need additional metdata (who is being thanked, which tip, etc) +} +// Opaque cursor used across paged APIs. Underlying bytes may change as paging +// strategies evolve. +message Cursor { + bytes value = 1 ; +} diff --git a/service/protos/src/main/proto/chat/v2/chat_service.proto b/definitions/code-vm/protos/src/main/proto/chat/v2/chat_service.proto similarity index 88% rename from service/protos/src/main/proto/chat/v2/chat_service.proto rename to definitions/code-vm/protos/src/main/proto/chat/v2/chat_service.proto index 47003e0d1..bce403d8e 100644 --- a/service/protos/src/main/proto/chat/v2/chat_service.proto +++ b/definitions/code-vm/protos/src/main/proto/chat/v2/chat_service.proto @@ -3,8 +3,8 @@ package code.chat.v2; option go_package = "github.com/code-payments/code-protobuf-api/generated/go/chat/v2;chat"; option java_package = "com.codeinc.gen.chat.v2"; option objc_class_prefix = "CPBChatV2"; -import "common/v1/model.proto"; -import "transaction/v2/transaction_service.proto"; +import "common/v1/code_model.proto"; +import "transaction/v2/code_transaction_service.proto"; import "google/protobuf/timestamp.proto"; service Chat { @@ -83,7 +83,7 @@ message GetChatsResponse { repeated ChatMetadata chats = 2 ; } message GetMessagesRequest { - common.v1.ChatId chat_id = 1; + ChatId chat_id = 1; ChatMemberId member_id = 2; common.v1.SolanaAccountId owner = 3; common.v1.Signature signature = 4; @@ -106,7 +106,7 @@ message GetMessagesResponse { repeated ChatMessage messages = 2 ; } message OpenChatEventStream { - common.v1.ChatId chat_id = 1; + ChatId chat_id = 1; ChatMemberId member_id = 2 ; common.v1.SolanaAccountId owner = 3; common.v1.Signature signature = 4; @@ -144,54 +144,32 @@ message StreamChatEventsResponse { message StartChatRequest { common.v1.SolanaAccountId owner = 1; common.v1.Signature signature = 2; - ChatMemberIdentity self = 3; oneof parameters { - StartTwoWayChatParameters two_way_chat = 4; + StartTipChatParameters tip_chat = 3; // GroupChatParameters group_chat = 4; } } -// StartTwoWayChatParameters contains the parameters required to start -// or recover a two way chat between the caller and the specified 'other_user'. -// -// The 'other_user' is currently the 'tip_address', normally retrieved from -// user.Identity.GetTwitterUser(username). -message StartTwoWayChatParameters { - // The account id of the user the caller wishes to chat with. - // - // This will be the `tip` (or equivalent) address. - common.v1.SolanaAccountId other_user = 1; - // The intent_id of the payment that initiated the chat/friendship. - // - // This field is optional. It is used as an optimization when the server has not - // yet observed the establishment of a friendship. In this case, the server will - // use the provided intent_id to verify the friendship. - // - // This is most likely to occur when initiating a chat with a user for the first - // time. - common.v1.IntentId intent_id = 2; - // The identity of the other user. - // - // Note: This can/should be removed with proper intent plumbing. - ChatMemberIdentity identity = 3; +// Starts a two-way chat between a tipper and tippee. Chat members are +// inferred from the 12 word public keys involved in the intent. Only +// the tippee can start the chat, and the tipper is anonymous if this +// is the first between the involved Code users. +message StartTipChatParameters { + // The tip's intent ID, which can be extracted from the reference in + // an ExchangeDataContent message content where the verb is RECEIVED_TIP. + common.v1.IntentId intent_id = 1; } message StartChatResponse { Result result = 1; enum Result { - OK = 0; - // DENIED indicates the caller is not allowed to start/join the chat. - DENIED = 1; - // INVALID_PRAMETER indicates one of the parameters is invalid. + OK = 0; + DENIED = 1; INVALID_PARAMETER = 2; - // PENDING indicates that the payment (for chat) intent is pending confirmation - // before the service will permit the creation of the chat. This can happen in - // cases where the block chain is particularly slow (beyond our RPC timeouts). - PENDING = 3; } - // The chat to use if the RPC was successful. + // The chat to use if the RPC was successful ChatMetadata chat = 2; } message SendMessageRequest { - common.v1.ChatId chat_id = 1; + ChatId chat_id = 1; ChatMemberId member_id = 2 ; // Allowed content types that can be sent by client: // - TextContent @@ -214,7 +192,7 @@ message SendMessageResponse { ChatMessage message = 2; } message AdvancePointerRequest { - common.v1.ChatId chat_id = 1; + ChatId chat_id = 1; Pointer pointer = 2; common.v1.SolanaAccountId owner = 3; common.v1.Signature signature = 4; @@ -230,7 +208,7 @@ message AdvancePointerResponse { } } message RevealIdentityRequest { - common.v1.ChatId chat_id = 1; + ChatId chat_id = 1; ChatMemberId member_id = 2; ChatMemberIdentity identity = 3; common.v1.SolanaAccountId owner = 4; @@ -248,7 +226,7 @@ message RevealIdentityResponse { ChatMessage message = 2; } message SetMuteStateRequest { - common.v1.ChatId chat_id = 1; + ChatId chat_id = 1; ChatMemberId member_id = 2 ; bool is_muted = 3; common.v1.SolanaAccountId owner = 4; @@ -264,7 +242,7 @@ message SetMuteStateResponse { } } message SetSubscriptionStateRequest { - common.v1.ChatId chat_id = 1; + ChatId chat_id = 1; ChatMemberId member_id = 2 ; bool is_subscribed = 3; common.v1.SolanaAccountId owner = 4; @@ -280,7 +258,7 @@ message SetSubscriptionStateResponse { } } message NotifyIsTypingRequest { - common.v1.ChatId chat_id = 1; + ChatId chat_id = 1; ChatMemberId member_id = 2 ; bool is_typing = 3; common.v1.SolanaAccountId owner = 4; @@ -294,6 +272,11 @@ message NotifyIsTypingResponse { CHAT_NOT_FOUND = 2; } } +message ChatId { + // Sufficient space is left for a consistent hash value, though other types + // of values may be used. + bytes value = 1 ; +} message ChatMessageId { // Guaranteed to be a time-based UUID. This should be used to construct a // consistently ordered message history based on time using a simple byte @@ -325,7 +308,7 @@ enum PointerType { // todo: Support is_verified in a clean way message ChatMetadata { // Globally unique ID for this chat - common.v1.ChatId chat_id = 1; + ChatId chat_id = 1; // The type of chat ChatType type = 2 ; // The chat title, which will be localized by server when applicable diff --git a/definitions/code-vm/protos/src/main/proto/common/v1/code_model.proto b/definitions/code-vm/protos/src/main/proto/common/v1/code_model.proto new file mode 100644 index 000000000..6d681311e --- /dev/null +++ b/definitions/code-vm/protos/src/main/proto/common/v1/code_model.proto @@ -0,0 +1,134 @@ +syntax = "proto3"; +package code.common.v1; +option go_package = "github.com/code-payments/code-protobuf-api/generated/go/common/v1;common"; +option java_package = "com.codeinc.gen.common.v1"; +option objc_class_prefix = "CPBCommonV1"; +import "google/protobuf/duration.proto"; +import "google/protobuf/timestamp.proto"; + +// AccountType associates a type to an account, which infers how an account is used +// within the Code ecosystem. +enum AccountType { + UNKNOWN = 0; + PRIMARY = 1; + TEMPORARY_INCOMING = 2; + TEMPORARY_OUTGOING = 3; + BUCKET_1_KIN = 4; + BUCKET_10_KIN = 5; + BUCKET_100_KIN = 6; + BUCKET_1_000_KIN = 7; + BUCKET_10_000_KIN = 8; + BUCKET_100_000_KIN = 9; + BUCKET_1_000_000_KIN = 10; + LEGACY_PRIMARY_2022 = 11; + REMOTE_SEND_GIFT_CARD = 12; + RELATIONSHIP = 13; + SWAP = 14; +} +// SolanaAccountId is a raw binary Ed25519 public key for a Solana account +message SolanaAccountId { + bytes value = 1 ; +} +// InstructionAccount is an account public key used within the context of +// an instruction. +message InstructionAccount { + SolanaAccountId account = 1; + bool is_signer = 2; + bool is_writable = 3; +} +// Transaction is a raw binary Solana transaction +message Transaction { + // Maximum size taken from: https://github.com/solana-labs/solana/blob/39b3ac6a8d29e14faa1de73d8b46d390ad41797b/sdk/src/packet.rs#L9-L13 + bytes value = 1 ; +} +// Blockhash is a raw binary Solana blockchash +message Blockhash { + bytes value = 1 ; +} +// Signature is a raw binary Ed25519 signature +message Signature { + bytes value = 1 ; +} +// IntentId is a client-side generated ID that maps to an intent to perform actions +// on the blockchain fulfilled by the Code sequencer. +message IntentId { + bytes value = 1 ; +} +// UserId is a globally unique identifier for a user within Code +// +// Note: Users outside Code are modelled as relationship accounts +message UserId { + bytes value = 1 ; +} +// DataContainerId is a globally unique identifier for a container where a user +// can store a copy of their data +message DataContainerId { + bytes value = 1 ; +} +// DeviceToken is an opaque token used to verify whether a device real +message DeviceToken { + string value = 1 ; +} +// AppInstallId is a unque ID tied to a client app installation. It does not +// identify a device. Value should remain private and not be shared across +// installs. +message AppInstallId { + string value = 1 ; +} +// PhoneNumber is an E.164 phone number +message PhoneNumber { + // Regex provided by Twilio here: https://www.twilio.com/docs/glossary/what-e164#regex-matching-for-e164 + string value = 1; +} +// Domain is a hostname +message Domain { + string value = 1 ; +} +// Relationship is a set of identifiers that a user can establish a relationship +// with. +message Relationship { + oneof type { + common.v1.Domain domain = 1; + } +} +// Hash is a raw binary 32 byte hash value +message Hash { + bytes value = 1 ; +} +// Locale is a user locale consisting of a combination of language, script and region +message Locale { + string value = 1; +} +// UUID is a 16 byte UUID value +message UUID { + bytes value = 1 ; +} +// Request is a generic wrapper for gRPC requests +message Request { + string version = 1; + string service = 2; + string method = 3; + bytes body = 4; +} +// Response is a generic wrapper for gRPC responses +message Response { + Result result = 1; + bytes body = 2; + string message = 3; + enum Result { + OK = 0; + ERROR = 1; + } +} +message ServerPing { + // Timestamp the ping was sent on the stream, for client to get a sense + // of potential network latency + google.protobuf.Timestamp timestamp = 1; + // The delay server will apply before sending the next ping + google.protobuf.Duration ping_delay = 2; +} +message ClientPong { + // Timestamp the Pong was sent on the stream, for server to get a sense + // of potential network latency + google.protobuf.Timestamp timestamp = 1; +} diff --git a/definitions/code-vm/protos/src/main/proto/contact/v1/code_contact_list_service.proto b/definitions/code-vm/protos/src/main/proto/contact/v1/code_contact_list_service.proto new file mode 100644 index 000000000..c28545e59 --- /dev/null +++ b/definitions/code-vm/protos/src/main/proto/contact/v1/code_contact_list_service.proto @@ -0,0 +1,106 @@ +syntax = "proto3"; +package code.contact.v1; +option go_package = "github.com/code-payments/code-protobuf-api/generated/go/contact/v1;contact"; +option java_package = "com.codeinc.gen.contact.v1"; +option objc_class_prefix = "CPBContactV1"; +import "common/v1/code_model.proto"; + +service ContactList { + // AddContacts adds a batch of contacts to a user's contact list + rpc AddContacts(AddContactsRequest) returns (AddContactsResponse); + // RemoveContacts removes a batch of contacts from a user's contact list + rpc RemoveContacts(RemoveContactsRequest) returns (RemoveContactsResponse); + // GetContacts gets a subset of contacts from a user's contact list + rpc GetContacts(GetContactsRequest) returns (GetContactsResponse); +} +message AddContactsRequest { + // The public key of the owner account that signed this request message. + common.v1.SolanaAccountId owner_account_id = 1; + // The signature is of serialize(AddContactsRequest) without this field set + // using the private key of owner_account_id. This provides an authentication + // mechanism to the RPC. + common.v1.Signature signature = 2; + // The data container for the copy of the contact list being added to. + common.v1.DataContainerId container_id = 3; + // The set of contacts to add to the contact list + repeated common.v1.PhoneNumber contacts = 4 ; + +} +message AddContactsResponse { + Result result = 1; + enum Result { + OK = 0; + } + // The contacts' current status keyed by phone number. This is an optimization + // so that clients can populate initial state without needing an extra network + // call. + map contact_status = 2; +} +message RemoveContactsRequest { + // The public key of the owner account that signed this request message. + common.v1.SolanaAccountId owner_account_id = 1; + // The signature is of serialize(RemoveContactsRequest) without this field + // set using the private key of owner_account_id. This provides an + // authentication mechanism to the RPC. + common.v1.Signature signature = 2; + // The data container for the copy of the contact list being removed from. + common.v1.DataContainerId container_id = 3; + // The set of contacts to remove from the contact list + repeated common.v1.PhoneNumber contacts = 4 ; +} +message RemoveContactsResponse { + Result result = 1; + enum Result { + OK = 0; + } +} +message GetContactsRequest { + // The public key of the owner account that signed this request message. + common.v1.SolanaAccountId owner_account_id = 1; + // The signature is of serialize(GetContactsRequest) without this field set + // using the private key of owner_account_id. This provides an authentication + // mechanism to the RPC. + common.v1.Signature signature = 2; + // The data container for the copy of the contact list being fetched. + common.v1.DataContainerId container_id = 3; + // The page token, which is retreived from a previous response, to get the next + // set of contacts. The first page is returned when not set. + PageToken page_token = 4; + // Filter out contacts that have an association with Code. This includes users + // that have both been invited and registered with the app. + bool include_only_in_app_contacts = 5; +} +message GetContactsResponse { + Result result = 1; + enum Result { + OK = 0; + } + // A page of contacts + repeated Contact contacts = 2; + // The page token to include in a subsequent request to get the next set of + // contacts. This will not be set for the last response in the list of + // pages. + PageToken next_page_token = 3; +} +message Contact { + // The contact's phone number + common.v1.PhoneNumber phone_number = 1; + // The contact's current status + ContactStatus status = 2; +} +message ContactStatus { + // Flag to indicate whether a user has registered with Code and used the app + // at least once. + bool is_registered = 1; + // Flag to indicate whether a user has been invited to Code. + // + // todo: This field will be deprecated after the invite phase is complete. + bool is_invited = 2; + // Flag to indicate whether a user's invitation to Code has been revoked. + // + // todo: This field will be deprecated after the invite phase is complete. + bool is_invite_revoked = 3; +} +message PageToken { + bytes value = 1 ; +} diff --git a/service/protos/src/main/proto/currency/v1/currency_service.proto b/definitions/code-vm/protos/src/main/proto/currency/v1/code_currency_service.proto similarity index 100% rename from service/protos/src/main/proto/currency/v1/currency_service.proto rename to definitions/code-vm/protos/src/main/proto/currency/v1/code_currency_service.proto diff --git a/definitions/code-vm/protos/src/main/proto/device/v1/code_device_service.proto b/definitions/code-vm/protos/src/main/proto/device/v1/code_device_service.proto new file mode 100644 index 000000000..37e2daf74 --- /dev/null +++ b/definitions/code-vm/protos/src/main/proto/device/v1/code_device_service.proto @@ -0,0 +1,49 @@ +syntax = "proto3"; +package code.device.v1; +option go_package = "github.com/code-payments/code-protobuf-api/generated/go/device/v1;device"; +option java_package = "com.codeinc.gen.device.v1"; +option objc_class_prefix = "CPBDevicetV1"; +import "common/v1/code_model.proto"; + +service Device { + // RegisterLoggedInAccounts registers a set of owner accounts logged for + // an app install. Currently, a single login is enforced per app install. + // After using GetLoggedInAccounts to detect stale logins, clients can use + // this RPC to update the set of accounts with valid login sessions. + rpc RegisterLoggedInAccounts(RegisterLoggedInAccountsRequest) returns (RegisterLoggedInAccountsResponse); + // GetLoggedInAccounts gets the set of logged in accounts for an app install. + // Clients can use this RPC to detect stale logins for boot out of the app. + rpc GetLoggedInAccounts(GetLoggedInAccountsRequest) returns (GetLoggedInAccountsResponse); +} +message RegisterLoggedInAccountsRequest { + common.v1.AppInstallId app_install = 1; + // The set of owners logged into the app install. Setting an empty value + // indicates there are no logged in users. We allow for more than one owner + // in the spec with a repeated field to be flexible in the future. + repeated common.v1.SolanaAccountId owners = 2 ; + // Signature values must appear in the exact order their respecitive signing + // owner account appears in the owners field. All signatures should be generated + // without any other signature values set. + repeated common.v1.Signature signatures = 3 ; +} +message RegisterLoggedInAccountsResponse { + Result result = 1; + enum Result { + OK = 0; + INVALID_OWNER = 1; + } + // Set of invalid owner accounts detected in the request. An owner account + // can be invalid for several reasons: not phone verified, timelock account + // unlocked, etc. Value is set when result is INVALID_OWNER. + repeated common.v1.SolanaAccountId invalid_owners = 2 ; +} +message GetLoggedInAccountsRequest { + common.v1.AppInstallId app_install = 1; +} +message GetLoggedInAccountsResponse { + Result result = 1; + enum Result { + OK = 0; + } + repeated common.v1.SolanaAccountId owners = 2 ; +} diff --git a/definitions/code-vm/protos/src/main/proto/invite/v2/code_invite_service.proto b/definitions/code-vm/protos/src/main/proto/invite/v2/code_invite_service.proto new file mode 100644 index 000000000..19936594d --- /dev/null +++ b/definitions/code-vm/protos/src/main/proto/invite/v2/code_invite_service.proto @@ -0,0 +1,91 @@ +syntax = "proto3"; +package code.invite.v2; +option go_package = "github.com/code-payments/code-protobuf-api/generated/go/invite/v2;invite"; +option java_package = "com.codeinc.gen.invite.v2"; +option objc_class_prefix = "CPBInviteV2"; +import "common/v1/code_model.proto"; + +service Invite { + // GetInviteCount gets the number of invites that a user can send out. + rpc GetInviteCount(GetInviteCountRequest) returns (GetInviteCountResponse); + // InvitePhoneNumber invites someone to join via their phone number. A phone number + // can only be invited once by a unique user or invite code. This is to avoid having + // a phone number consuming more than one invite count globally. + rpc InvitePhoneNumber(InvitePhoneNumberRequest) returns (InvitePhoneNumberResponse); + // GetInvitationStatus gets a phone number's invitation status. + rpc GetInvitationStatus(GetInvitationStatusRequest) returns (GetInvitationStatusResponse); +} +message GetInviteCountRequest { + // The user to query for their invite count + common.v1.UserId user_id = 1; +} +message GetInviteCountResponse { + Result result = 1; + enum Result { + OK = 0; + } + // The number of invites the user is allowed to issue. + uint32 invite_count = 2; +} +message InvitePhoneNumberRequest { + // The source for the invite. One of these values must be present + oneof source { + common.v1.UserId user = 1; + InviteCode invite_code = 3; + } + // The phone number receiving the invite. + common.v1.PhoneNumber receiver = 2; +} +message InvitePhoneNumberResponse { + Result result = 1; + enum Result { + OK = 0; + // The source exceeded their invite count and is restricted from issuing + // further invites. + INVITE_COUNT_EXCEEDED = 1; + // The receiver phone number has already been invited. Regardless of who + // invited it, the source's invite count is not decremented when this is + // returned. + ALREADY_INVITED = 2; + // The source user has not been invited. + USER_NOT_INVITED = 3; + // The receiver phone number failed validation. + INVALID_RECEIVER_PHONE_NUMBER = 4; + // The invite code doesn't exist. + INVITE_CODE_NOT_FOUND = 5; + // The invite code has been revoked. + INVITE_CODE_REVOKED = 6; + // The invite code has expired. + INVITE_CODE_EXPIRED = 7; + } +} +message GetInvitationStatusRequest { + // The user being queried for their invitation status. + common.v1.UserId user_id = 1; +} +message GetInvitationStatusResponse { + Result result = 1; + enum Result { + OK = 0; + } + // The user's invitation status + InvitationStatus status = 2; +} +message InviteCode { + // Regex for invite codes + string value = 1 ; +} +message PageToken { + bytes value = 1 ; +} +enum InvitationStatus { + // The phone number has never been invited. + NOT_INVITED = 0; + // The phone number has been invited at least once. + INVITED = 1; + // The phone number has been invited and used the app at least once via a + // phone verified account creation or login. + REGISTERED = 2; + // The phone number was invited, but revoked at a later time. + REVOKED = 3; +} diff --git a/definitions/code-vm/protos/src/main/proto/messaging/v1/code_messaging_service.proto b/definitions/code-vm/protos/src/main/proto/messaging/v1/code_messaging_service.proto new file mode 100644 index 000000000..e75005d68 --- /dev/null +++ b/definitions/code-vm/protos/src/main/proto/messaging/v1/code_messaging_service.proto @@ -0,0 +1,330 @@ +syntax = "proto3"; +package code.messaging.v1; +option go_package = "github.com/code-payments/code-protobuf-api/generated/go/messaging/v1;messaging"; +option java_package = "com.codeinc.gen.messaging.v1"; +option objc_class_prefix = "CPBMessagingV1"; +import "common/v1/code_model.proto"; +import "transaction/v2/code_transaction_service.proto"; + +import "google/protobuf/timestamp.proto"; +service Messaging { + // OpenMessageStream opens a stream of messages. Messages are routed using the + // public key of a rendezvous keypair derived by both the sender and the + // recipient of the messages. The sender may be a client or server. + // + // Messages are expected to be acked once they have been processed by the client. + // Ack'd messages will no longer be delivered on future OpenMessageStream calls, + // and are eligible for deletion from the service. Clients should, however, handle + // duplicate delivery of messages. + // + // For grabbing a bill, the expected flow is as follows: + // 1. The payment sender creates a cash scan code + // 2. The payment sender calls OpenMessageStream on the rendezvous public key, which is + // derived by using sha256(scan payload) as the keypair seed. + // 3. The payment recipient scans the code and uses SendMessage to send their account ID + // back to the sender via the rendezvous public key. + // 4. The payment sender receives the message, submits the intent, and closes the stream. + // + // For receiving a bill of requested value, the expected flow is as follows: + // 1. The payment recipient uses SendMessage to send their account ID and payment amount to + // the sender via the rendezvous public key, which is derived by using sha256(scan payload) + // as the keypair seed. + // 2. The payment recipient calls OpenMessageStream on the rendezvous public key to listen + // for status messages generated by client/server. It must ignore the original message it sent + // as part of step 1. + // 3. The payment recipient creates a payment request scan code + // 4. The payment sender calls PollMessages on the rendezvous public key. This is ok because + // we know the message exists per step 1, and doesn't actually incur a long poll. This is a + // required hack because we don't have the infrastructure in place to allow multiple listens + // on the same stream, and the recipient needs real-time status updates. + // 5. The payment sender receives the message (any status messages are ignored), and submits the + // intent. + // 6. The payment recipient observes status message (eg. IntentSubmitted, ClientRejectedPayment, + // WebhookCalled) for payment state. + // 7. The payment recipient closes the stream once the payment hits a terminal state, or times out. + // + // For logging in, the expected flow is as follows: + // 1. The third party uses SendMessage to send their login challenge to the user via the rendezvous + // public key, which is derived by using sha256(scan payload) as the keypair seed. + // 2. The third party calls OpenMessageStream on the rendezvous public key to listen for status + // messages generated by server. It must ignore the original message it sent as part of step 1. + // 3. The third party creates a login scan code + // 4. The user logging in calls PollMessages on the rendezvous public key. This is ok because + // we know the message exists per step 1, and doesn't actually incur a long poll. This is a + // required hack because we don't have the infrastructure in place to allow multiple listens + // on the same stream, and the recipient needs real-time status updates. + // 5. The user logging in receives the message (any status messages are ignored), verifies it, + // then submits a login attempt. + // 6. The third party observes status message (eg. IntentSubmitted, ClientRejectedLogin, + // WebhookCalled) for login state. + // 7. The third party closes the stream once the login hits a terminal state, or times out. + rpc OpenMessageStream(OpenMessageStreamRequest) returns (stream OpenMessageStreamResponse); + // OpenMessageStreamWithKeepAlive is like OpenMessageStream, but enables a ping/pong + // keepalive to determine the health of the stream at both the client and server. + // + // The keepalive protocol is as follows: + // 1. Client initiates a stream by sending an OpenMessageStreamRequest. + // 2. Upon stream initialization, server begins the keepalive protocol. + // 3. Server sends a ping to the client. + // 4. Client responds with a pong as fast as possible, making note of + // the delay for when to expect the next ping. + // 5. Steps 3 and 4 are repeated until the stream is explicitly terminated + // or is deemed to be unhealthy. + // + // Client notes: + // * Client should be careful to process messages async, so any responses to pings are + // not delayed. + // * Clients should implement a reasonable backoff strategy upon continued timeout failures. + // * Clients that abuse pong messages may have their streams terminated by server. + // + // At any point in the stream, server will respond with messages in real time as + // they are observed. Messages sent over the stream should not affect the ping/pong + // protocol timings. Individual protocols for payment flows remain the same, and are + // documented in OpenMessageStream. + // + // Note: This API will enforce OpenMessageStreamRequest.signature is set as part of migration + // to this newer protocol + rpc OpenMessageStreamWithKeepAlive(stream OpenMessageStreamWithKeepAliveRequest) returns (stream OpenMessageStreamWithKeepAliveResponse); + // PollMessages is like OpenMessageStream, but uses a polling flow for receiving + // messages. Updates are not real-time and depedent on the polling interval. + // This RPC supports all message types. + // + // This is a temporary RPC until OpenMessageStream can be built out generically on + // both client and server, while supporting things like multiple listeners. + rpc PollMessages(PollMessagesRequest) returns (PollMessagesResponse); + // AckMessages acks one or more messages that have been successfully delivered to + // the client. + rpc AckMessages(AckMessagesRequest) returns (AckMesssagesResponse); + // SendMessage sends a message. + rpc SendMessage(SendMessageRequest) returns (SendMessageResponse); +} +message OpenMessageStreamRequest { + RendezvousKey rendezvous_key = 1; + // The signature is of serialize(OpenMessageStreamRequest) using rendezvous_key. + // + // todo: Make required once clients migrate + common.v1.Signature signature = 2; +} +message OpenMessageStreamResponse { + repeated Message messages = 1 ; +} +message OpenMessageStreamWithKeepAliveRequest { + oneof request_or_pong { + OpenMessageStreamRequest request = 1; + common.v1.ClientPong pong = 2; + } +} +message OpenMessageStreamWithKeepAliveResponse { + oneof response_or_ping { + OpenMessageStreamResponse response = 1; + common.v1.ServerPing ping = 2; + } +} +message PollMessagesRequest { + RendezvousKey rendezvous_key = 1; + // The signature is of serialize(PollMessagesRequest) using rendezvous_key. + common.v1.Signature signature = 2; +} +message PollMessagesResponse { + repeated Message messages = 1 ; +} +message AckMessagesRequest { + RendezvousKey rendezvous_key = 1; + repeated MessageId message_ids = 2 ; +} +message AckMesssagesResponse { + Result result = 1; + enum Result { + OK = 0; + } +} +message SendMessageRequest { + // The message to send. Types of messages clients can send are restricted. + Message message = 1; + // The rendezvous key that the message should be routed to. + RendezvousKey rendezvous_key = 2; + // The signature is of serialize(Message) using the PrivateKey of the keypair. + common.v1.Signature signature = 3; +} +message SendMessageResponse { + Result result = 1; + enum Result { + OK = 0; + NO_ACTIVE_STREAM = 1; + } + // Set if result == OK. + MessageId message_id = 2; +} +// RendezvousKey is a unique key pair, typically derived from a scan code payload, +// which is used to establish a secure communication channel anonymously to coordinate +// a flow using messages. +message RendezvousKey { + bytes value = 1 ; +} +// MessageId identifies a message. It is only guaranteed to be unique when +// paired with a destination (i.e. the rendezvous public key). +message MessageId { + bytes value = 1 ; +} +// Request that a pulled out bill be sent to the requested address. +// +// This message type is only initiated by clients. +message RequestToGrabBill { + // Requestor is the Kin token account on Solana to which a payment should be sent. + common.v1.SolanaAccountId requestor_account = 1; +} +// Request that a bill of a requested value is created and sent to the requested +// address. +// +// This message type is only initiated by clients. +message RequestToReceiveBill { + // Requestor is the Kin token account on Solana to which a payment should be sent. + common.v1.SolanaAccountId requestor_account = 1; + // The exchange data for the requested bill value. + oneof exchange_data { + // An exact amount of Kin. Payment is guaranteed to transfer the specified + // quarks in the requested currency and exchange rate. + // + // Only supports Kin. Use exchange_data.partial for fiat amounts. + transaction.v2.ExchangeData exact = 2; + // Fiat amount request. The amount of Kin is determined at time of payment + // with a recent exchange rate provided by the paying client and validatd + // by server. + // + // Only supports fiat amounts. Use exchange_data.exact for Kin. + transaction.v2.ExchangeDataWithoutRate partial = 3; + } + // + // Optional fields below to identify a domain requesting to receive a bill. + // Verification of the domain is optional. When verified, clients can establish + // relationships and third parties will by able to identify users with that + // account after payment is made. + // + // Note on field requirements: + // - Verified: All of domain, verifier, signature and rendezvous_key are required + // - Unverified: Only domain is requried + // + // The third-party's domain name, which is its primary identifier. Server + // guarantees to perform domain verification against the verifier account. + common.v1.Domain domain = 4; + // Owner account owned by the third party used in domain verification. + common.v1.SolanaAccountId verifier = 5; + // Signature of this message using the verifier private key, which in addition + // to domain verification, authenticates the third party. + common.v1.Signature signature = 6; + // Rendezvous key to avoid replay attacks + RendezvousKey rendezvous_key = 7; + // Additional fee payments splitting the requested amount. This is in addition + // to the hard-coded Code $0.01 USD fee. + repeated transaction.v2.AdditionalFeePayment additional_fees = 8; +} +// A status update on a stream to indicate a scan code was scanned. This can appear +// multiple times for the same stream. +// +// This message type is only initiated by client +message CodeScanned { + // Timestamp the client scanned the code + google.protobuf.Timestamp timestamp = 1; +} +// Payment is rejected by the client +// +// This message type is only initiated by clients +message ClientRejectedPayment { + common.v1.IntentId intent_id = 1; +} +// Intent was submitted via SubmitIntent +// +// This message type is only initiated by server +message IntentSubmitted { + common.v1.IntentId intent_id = 1; + // Metadata is available for intents where it can be safely propagated publicly. + // Anything else requires an additional authenticated RPC call (eg. login). + transaction.v2.Metadata metadata = 2; +} +// Webhook was successfully called +// +// This message type is only initiated by server +message WebhookCalled { + // Estimated time webhook was received + google.protobuf.Timestamp timestamp = 1; +} +// Request that an account logs in +// +// This message type is only initiated by third-parties through the SDK. +message RequestToLogin { + // The third-party's domain name, which is its primary identifier. Server + // guarantees to perform domain verification against the verifier account. + // + // Clients should expect subdomains for future feature compatiblity, but must + // use the ASCII base domain in the RELATIONSHIP account derivation strategy. + common.v1.Domain domain = 1; + // Deprecated nonce value, which is replaced by the rendezvous_key field which + // is effectively derived off a random nonce. + reserved 2; + // Reserved for a timestamp field, which may be used in the future. + reserved 3; + // Owner account owned by the third party used in domain verification. + common.v1.SolanaAccountId verifier = 4; + // Signature of this message using the verifier private key, which in addition + // to domain verification, authenticates the third party. + common.v1.Signature signature = 5; + // Rendezvous key to avoid replay attacks + RendezvousKey rendezvous_key = 6; +} +// Login is rejected by the client +// +// This message type is only initiated by user clients +message ClientRejectedLogin { + // Timestamp the login was rejected + google.protobuf.Timestamp timestamp = 4; +} +// Client has received an aidrop from server +// +// This message type is only initiated by server. +message AirdropReceived { + // The type of airdrop received + transaction.v2.AirdropType airdrop_type = 1; + // Exchange data relating to the amount of Kin and fiat value of the airdrop + transaction.v2.ExchangeData exchange_data = 2; + // Time the airdrop was received + google.protobuf.Timestamp timestamp = 3; +} +message Message { + // MessageId is the Id of the message. This ID is generated by the + // server, and will _always_ be set when receiving a message. + // + // Server generates the message to: + // 1. Reserve the ability for any future ID changes + // 2. Prevent clients attempting to collide message IDs. + MessageId id = 1; + // The signature sent from SendMessageRequest, which will be injected by server. + // This enables clients to ensure no MITM attacks were performed to hijack contents + // of the typed message. This is only applicable for messages not generated by server. + common.v1.Signature send_message_request_signature = 3; + // Next field number is 13 + oneof kind { + // + // Section: Cash + // + RequestToGrabBill request_to_grab_bill = 2; + // + // Section: Payment Requests + // + RequestToReceiveBill request_to_receive_bill = 5; + CodeScanned code_scanned = 6; + ClientRejectedPayment client_rejected_payment = 7; + IntentSubmitted intent_submitted = 8; + WebhookCalled webhook_called = 9; + // + // Section: Login + // + RequestToLogin request_to_login = 10; + ClientRejectedLogin client_rejected_login = 12; + // + // Section: Airdrops + // + AirdropReceived airdrop_received = 4; + } + // Reserved for deprecated LoginAttempt field + reserved 11; +} diff --git a/definitions/code-vm/protos/src/main/proto/micropayment/v1/code_micro_payment_service.proto b/definitions/code-vm/protos/src/main/proto/micropayment/v1/code_micro_payment_service.proto new file mode 100644 index 000000000..68a38719e --- /dev/null +++ b/definitions/code-vm/protos/src/main/proto/micropayment/v1/code_micro_payment_service.proto @@ -0,0 +1,107 @@ +syntax = "proto3"; +package code.micropayment.v1; +option go_package = "github.com/code-payments/code-protobuf-api/generated/go/micropayment/v1;micropayment"; +option java_package = "com.codeinc.gen.micropayment.v1"; +option objc_class_prefix = "APBMicroPaymentV1"; +import "common/v1/code_model.proto"; + +// todo: Migrate this to a generic "request" service +service MicroPayment { + // GetStatus gets basic request status + rpc GetStatus(GetStatusRequest) returns (GetStatusResponse); + // RegisterWebhook registers a webhook for a request + // + // todo: Once Kik codes can encode the entire payment request details, we can + // remove the messaging service component and have a Create RPC that + // reserves the intent ID with payment details, plus registers the webhook + // at the same time. Until that's possible, we're stuck with two RPC calls. + rpc RegisterWebhook(RegisterWebhookRequest) returns (RegisterWebhookResponse); + // Codify adds a trial micro paywall to any URL + rpc Codify(CodifyRequest) returns (CodifyResponse); + // GetPathMetadata gets codified website metadata for a given path + // + // Important Note: This RPC's current implementation is insecure and + // it's sole design is to enable PoC and trials. + rpc GetPathMetadata(GetPathMetadataRequest) returns (GetPathMetadataResponse); +} +message GetStatusRequest { + common.v1.IntentId intent_id = 1; +} +message GetStatusResponse { + // Does the payment request exist? + bool exists = 1; + // Has the user scanned the code at least once? + bool code_scanned = 2; + // Has the user sumbmitted a payment? + bool intent_submitted = 3; +} +message RegisterWebhookRequest { + common.v1.IntentId intent_id = 1; + string url = 2 ; +} +message RegisterWebhookResponse { + Result result = 1; + enum Result { + OK = 0; + // A webhook has already been registered + ALREADY_REGISTERED = 1; + // A request does not exist for the provided intent ID + REQUEST_NOT_FOUND = 2; + // A user has already submitted a payment + INTENT_EXISTS = 3; + // The webhook URL is invalid + INVALID_URL = 4; + } +} +message CodifyRequest { + // The URL to Codify + string url = 1 ; + // ISO 4217 alpha-3 currency code the payment should be made in + string currency = 2; + // The amount that should be paid in the native currency + double native_amount = 3; + // The verified owner account public key + common.v1.SolanaAccountId owner_account = 4; + // The primary account public key where payment will be sent + common.v1.SolanaAccountId primary_account = 5; +; + // The signature is of serialize(CodifyRequest) without this field set using the + // private key of the owner account. This provides an authentication mechanism + // to the RPC and can be used to validate payment details. + common.v1.Signature signature = 6; +} +message CodifyResponse { + Result result = 1; + enum Result { + OK = 0; + // The URL to Codify is invalid + INVALID_URL = 1; + // The primary account is invalid + INVALID_ACCOUNT = 2; + // The currency isn't supported for micro payments + UNSUPPORTED_CURRENCY = 3; + // The payment amount exceeds the minimum/maximum allowed amount + NATIVE_AMOUNT_EXCEEDS_LIMIT = 4; + } + // The URL to view the content with a Code micro paywall + string codified_url = 2; +} +message GetPathMetadataRequest { + string path = 1; +} +message GetPathMetadataResponse { + Result result = 1; + enum Result { + OK = 0; + NOT_FOUND = 1; + } + // The account where the payment should be sent to + common.v1.SolanaAccountId destination = 2; + + // ISO 4217 alpha-3 currency code the payment should be made in + string currency = 3; + // The amount that should be paid in the native currency + double native_amount = 4; + // The URL to redirect upon successful payment + string redirct_url = 5; +} diff --git a/definitions/code-vm/protos/src/main/proto/phone/v1/code_phone_verification_service.proto b/definitions/code-vm/protos/src/main/proto/phone/v1/code_phone_verification_service.proto new file mode 100644 index 000000000..e82bde7dc --- /dev/null +++ b/definitions/code-vm/protos/src/main/proto/phone/v1/code_phone_verification_service.proto @@ -0,0 +1,117 @@ +syntax = "proto3"; +package code.phone.v1; +option go_package = "github.com/code-payments/code-protobuf-api/generated/go/phone/v1;phone"; +option java_package = "com.codeinc.gen.phone.v1"; +option objc_class_prefix = "CPBPhoneV1"; +import "common/v1/code_model.proto"; + +service PhoneVerification { + // SendVerificationCode sends a verification code to the provided phone number + // over SMS. If an active verification is already taking place, the existing code + // will be resent. + rpc SendVerificationCode(SendVerificationCodeRequest) returns (SendVerificationCodeResponse); + // CheckVerificationCode validates a verification code. On success, a one-time use + // token to link an owner account is provided. + rpc CheckVerificationCode(CheckVerificationCodeRequest) returns (CheckVerificationCodeResponse); + // GetAssociatedPhoneNumber gets the latest verified phone number linked to an owner account. + rpc GetAssociatedPhoneNumber(GetAssociatedPhoneNumberRequest) returns (GetAssociatedPhoneNumberResponse); +} +message SendVerificationCodeRequest { + // The phone number to send a verification code over SMS to. + common.v1.PhoneNumber phone_number = 1; + // Device token for antispam measures against fake devices + common.v1.DeviceToken device_token = 2; +} +message SendVerificationCodeResponse { + Result result = 1; + enum Result { + OK = 0; + // The phone number is not invited and cannot use Code. The SMS will not + // be sent until the user is invited. This result is only valid during + // the invitation stage of the application and won't apply after general + // public release. + NOT_INVITED = 1; + // SMS is rate limited (eg. by IP, phone number, etc) and was not sent. + // These will be set generously such that real users won't actually hit + // the limits. + RATE_LIMITED = 2; + // The phone number is not real because it fails Twilio lookup. + INVALID_PHONE_NUMBER = 3; + // The phone number is valid, but it maps to an unsupported type of phone + // like a landline or eSIM. + UNSUPPORTED_PHONE_TYPE = 4; + // The country associated with the phone number is not supported (eg. it + // is on the sanctioned list). + UNSUPPORTED_COUNTRY = 5; + // The device is not supported (eg. it fails device attestation checks) + UNSUPPORTED_DEVICE = 6; + } +} +message CheckVerificationCodeRequest { + // The phone number being verified. + common.v1.PhoneNumber phone_number = 1; + // The verification code received via SMS. + VerificationCode code = 2; +} +message CheckVerificationCodeResponse { + Result result = 1; + enum Result { + OK = 0; + // The provided verification code is invalid. The user may retry + // enterring the code if this is received. When max attempts are + // received, NO_VERIFICATION will be returned. + INVALID_CODE = 1; + // There is no verification in progress for the phone number. Several + // reasons this can occur include a verification being expired or having + // reached a maximum check threshold. The client must initiate a new + // verification using SendVerificationCode. + NO_VERIFICATION = 2; + // The call is rate limited (eg. by IP, phone number, etc). The code is + // not verified. + RATE_LIMITED = 3; + } + // The token used to associate an owner account to a user using the verified + // phone number. + PhoneLinkingToken linking_token = 2; +} +message GetAssociatedPhoneNumberRequest { + // The public key of the owner account that is being queried for a linked + // phone number. + common.v1.SolanaAccountId owner_account_id = 1; + // The signature is of serialize(GetAssociatedPhoneNumberRequest) without + // this field set using the private key of owner_account_id. This provides + // an authentication mechanism to the RPC. + common.v1.Signature signature = 2; +} +message GetAssociatedPhoneNumberResponse { + Result result = 1; + enum Result { + OK = 0; + // A phone number is not associated with the provided owner account. + NOT_FOUND = 1; + // The phone number exists, but is no longer invited + NOT_INVITED = 2; + // The phone number exists, but at least one timelock account is unlocked + UNLOCKED_TIMELOCK_ACCOUNT = 3; + } + // The latest phone number associated with the owner account. + common.v1.PhoneNumber phone_number = 2; + // State that determines whether a phone number is linked to the owner + // account. A phone number is linked if we can treat it as an alias. + // This is notably different from association, which answers the question + // of whether the number was linked at any point in time. + bool is_linked = 3; +} +message VerificationCode { + // A 4-10 digit numerical code. + string value = 2 ; +} +// A one-time use token that can be provided to the Identity service to link an +// owner account to a user with the verified phone number. The client should +// treat this token as opaque. +message PhoneLinkingToken { + // The verified phone number. + common.v1.PhoneNumber phone_number = 1; + // The code that verified the phone number. + VerificationCode code = 2; +} diff --git a/definitions/code-vm/protos/src/main/proto/push/v1/code_push_service.proto b/definitions/code-vm/protos/src/main/proto/push/v1/code_push_service.proto new file mode 100644 index 000000000..c054b730a --- /dev/null +++ b/definitions/code-vm/protos/src/main/proto/push/v1/code_push_service.proto @@ -0,0 +1,74 @@ +syntax = "proto3"; +package code.push.v1; +option go_package = "github.com/code-payments/code-protobuf-api/generated/go/push/v1;push"; +option java_package = "com.codeinc.gen.push.v1"; +option objc_class_prefix = "APBPushV1"; +import "common/v1/code_model.proto"; + +service Push { + // AddToken stores a push token in a data container. The call is idempotent + // and adding an existing valid token will not fail. Token types will be + // validated against the user agent and any mismatches will result in an + // INVALID_ARGUMENT status error. + // + // The token will be unlinked from any and all other accounts that it was + // previously bound to. + rpc AddToken(AddTokenRequest) returns (AddTokenResponse); + // RemoveToken removes the provided push token from the account. + // + // The provided token must be bound to the current account. + // Otherwise, the RPC will succeed with without removal. + rpc RemoveToken(RemoveTokenRequest) returns (RemoveTokenResponse); +} +enum TokenType { + UNKNOWN = 0; + // FCM registration token for an Android device + FCM_ANDROID = 1; + // FCM registration token or an iOS device + FCM_APNS = 2; +} +message AddTokenRequest { + // The public key of the owner account that signed this request message. + common.v1.SolanaAccountId owner_account_id = 1; + // The signature is of serialize(AddTokenRequest) without this field set + // using the private key of owner_account_id. This provides an authentication + // mechanism to the RPC. + common.v1.Signature signature = 2; + // The data container where the push token will be stored. + common.v1.DataContainerId container_id = 3; + // The push token to store + string push_token = 4 ; + // The type of push token + TokenType token_type = 5; + // The instance of the app install where the push token was generated. Ideally, + // the push token is unique to the install. + common.v1.AppInstallId app_install = 6; +} +message AddTokenResponse { + Result result = 1; + enum Result { + OK = 0; + // The push token is invalid and wasn't stored. + INVALID_PUSH_TOKEN = 1; + } +} +message RemoveTokenRequest { + // The public key of the owner account that signed this request message. + common.v1.SolanaAccountId owner_account_id = 1; + // The signature is of serialize(AddTokenRequest) without this field set + // using the private key of owner_account_id. This provides an authentication + // mechanism to the RPC. + common.v1.Signature signature = 2; + // The data container where the push token was stored. + common.v1.DataContainerId container_id = 3; + // The push token to remove. + string push_token = 4 ; + // The type of push token to remove. + TokenType token_type = 5; +} +message RemoveTokenResponse { + Result result = 1; + enum Result { + OK = 0; + } +} diff --git a/definitions/code-vm/protos/src/main/proto/transaction/v2/code_transaction_service.proto b/definitions/code-vm/protos/src/main/proto/transaction/v2/code_transaction_service.proto new file mode 100644 index 000000000..9f5b74883 --- /dev/null +++ b/definitions/code-vm/protos/src/main/proto/transaction/v2/code_transaction_service.proto @@ -0,0 +1,1036 @@ +syntax = "proto3"; +package code.transaction.v2; +option go_package = "github.com/code-payments/code-protobuf-api/generated/go/transaction/v2;transaction"; +option java_package = "com.codeinc.gen.transaction.v2"; +option objc_class_prefix = "APBTransactionV2"; +import "common/v1/code_model.proto"; +import "google/protobuf/any.proto"; +import "google/protobuf/timestamp.proto"; + +service Transaction { + // SubmitIntent is the mechanism for client and server to agree upon a set of + // client actions to execute on the blockchain using the Code sequencer for + // fulfillment. + // + // Transactions and virtual instructions are never exchanged between client and server. + // Instead, the required accounts and arguments for instructions known to each actor are + // exchanged to allow independent and local construction. + // + // Client and server are expected to fully validate the intent. Proofs will + // be provided for any parameter requiring one. Signatures should only be + // generated after approval. + // + // This RPC is not a traditional streaming endpoint. It bundles two unary calls + // to enable DB-level transaction semantics. + // + // The high-level happy path flow for the RPC is as follows: + // 1. Client initiates a stream and sends SubmitIntentRequest.SubmitActions + // 2. Server validates the intent, its actions and metadata + // 3a. If there are transactions or virtual instructions requiring the user's signature, + // then server returns SubmitIntentResponse.ServerParameters + // 3b. Otherwise, server returns SubmitIntentResponse.Success and closes the + // stream + // 4. For each transaction or virtual instruction requiring the user's signature, the client + // locally constructs it, performs validation and collects the signature + // 5. Client sends SubmitIntentRequest.SubmitSignatures with the signature + // list generated from 4 + // 6. Server validates all signatures are submitted and are the expected values + // using locally constructed transactions or virtual instructions. + // 7. Server returns SubmitIntentResponse.Success and closes the stream + // In the error case: + // * Server will return SubmitIntentResponse.Error and close the stream + // * Client will close the stream + rpc SubmitIntent(stream SubmitIntentRequest) returns (stream SubmitIntentResponse); + // GetIntentMetadata gets basic metadata on an intent. It can also be used + // to fetch the status of submitted intents. Metadata exists only for intents + // that have been successfully submitted. + rpc GetIntentMetadata(GetIntentMetadataRequest) returns (GetIntentMetadataResponse); + // GetPrivacyUpgradeStatus gets the status of a private transaction and the + // ability to upgrade it to permanent privacy. + rpc GetPrivacyUpgradeStatus(GetPrivacyUpgradeStatusRequest) returns (GetPrivacyUpgradeStatusResponse); + // GetPrioritizedIntentsForPrivacyUpgrade allows clients to get private + // intent actions that can be upgraded in a secure and verifiable manner. + rpc GetPrioritizedIntentsForPrivacyUpgrade(GetPrioritizedIntentsForPrivacyUpgradeRequest) returns (GetPrioritizedIntentsForPrivacyUpgradeResponse); + // GetLimits gets limits for money moving intents for an owner account in an + // identity-aware manner + rpc GetLimits(GetLimitsRequest) returns (GetLimitsResponse); + // CanWithdrawToAccount provides hints to clients for submitting withdraw intents. + // The RPC indicates if a withdrawal is possible, and how it should be performed. + rpc CanWithdrawToAccount(CanWithdrawToAccountRequest) returns (CanWithdrawToAccountResponse); + // Airdrop airdrops Kin to the requesting account + rpc Airdrop(AirdropRequest) returns (AirdropResponse); + // Swap performs an on-chain swap. The high-level flow mirrors SubmitIntent + // closely. However, due to the time-sensitive nature and unreliability of + // swaps, they do not fit within the broader intent system. This results in + // a few key differences: + // * Transactions are submitted on a best-effort basis outside of the Code + // Sequencer within the RPC handler + // * Balance changes are applied after the transaction has finalized + // * Transactions use recent blockhashes over a nonce + // SubmitIntent also operates on VM virtual instructions, whereas Swap uses + // Solana transactions. + // + // The transaction will have the following instruction format: + // 1. ComputeBudget::SetComputeUnitLimit + // 2. ComputeBudget::SetComputeUnitPrice + // 3. SwapValidator::PreSwap + // 4. Dynamic swap instruction + // 5. SwapValidator::PostSwap + // + // Note: Currently limited to swapping USDC to Kin. + // Note: Kin is deposited into the token account derived from the VM deposit PDA of the owner account. + rpc Swap(stream SwapRequest) returns (stream SwapResponse); + // DeclareFiatOnrampPurchaseAttempt is called whenever a user attempts to use a fiat + // onramp to purchase crypto for use in Code. + rpc DeclareFiatOnrampPurchaseAttempt(DeclareFiatOnrampPurchaseAttemptRequest) returns (DeclareFiatOnrampPurchaseAttemptResponse); +} +// +// Request and Response Definitions +// +message SubmitIntentRequest { + oneof request { + SubmitActions submit_actions = 1; + SubmitSignatures submit_signatures = 2; + } + message SubmitActions { + // The globally unique client generated intent ID. Use the original intent + // ID when operating on actions that mutate the intent. + common.v1.IntentId id = 1; + + // The verified owner account public key + common.v1.SolanaAccountId owner = 2; + // Additional metadata that describes the high-level intention + Metadata metadata = 3; + // The set of all ordered actions required to fulfill the intent + repeated Action actions = 4 ; + // The signature is of serialize(SubmitActions) without this field set using the + // private key of the owner account. This provides an authentication mechanism + // to the RPC. + common.v1.Signature signature = 5; + // Device token for antispam measures against fake devices + common.v1.DeviceToken device_token = 6; + } + message SubmitSignatures { + // The set of all signatures for each transaction or virtual instruction requiring + // signature from the authority accounts. + // + // The signature for a transaction is for the marshalled transaction. + // The signature for a virtual instruction is the hash of the marshalled instruction. + repeated common.v1.Signature signatures = 1 ; + } +} +message SubmitIntentResponse { + oneof response { + ServerParameters server_parameters = 1; + Success success = 2; + Error error = 3; + } + message ServerParameters { + // The set of all server paremeters required to fill missing transaction + // or virtual instruction details. Server guarantees to provide a message + // for each client action in an order consistent with the received action + // list. + repeated ServerParameter server_parameters = 1 ; + } + message Success { + Code code = 1; + enum Code { + // The intent was successfully created and is now scheduled. + OK = 0; + } + // todo: Revisit if we need side-effects. Clients are effecitively doing + // local simulation now with the privacy solution. + } + message Error { + Code code = 1; + enum Code { + // Denied by a guard (spam, money laundering, etc) + DENIED = 0; + // The intent is invalid. + INVALID_INTENT = 1; + // There is an issue with provided signatures. + SIGNATURE_ERROR = 2; + // Server detected client has stale state. + STALE_STATE = 3; + } + repeated ErrorDetails error_details = 2; + } +} +message GetIntentMetadataRequest { + // The intent ID to query + common.v1.IntentId intent_id = 1; + // The verified owner account public key when not signing with the rendezvous + // key. Only owner accounts involved in the intent can access the metadata. + common.v1.SolanaAccountId owner = 2; + // The signature is of serialize(GetIntentStatusRequest) without this field set + // using the private key of the rendezvous or owner account. This provides an + // authentication mechanism to the RPC. + common.v1.Signature signature = 3; +} +message GetIntentMetadataResponse { + Result result = 1; + enum Result { + OK = 0; + NOT_FOUND = 1; + } + Metadata metadata = 2; +} +message GetPrivacyUpgradeStatusRequest { + // The intent ID + common.v1.IntentId intent_id = 1; + // The action ID for private transfer + uint32 action_id = 2; +} +message GetPrivacyUpgradeStatusResponse { + Result result = 1; + enum Result { + OK = 0; + // The provided intent ID doesn't exist + INTENT_NOT_FOUND = 1; + // The provided action ID doesn't exist + ACTION_NOT_FOUND = 2; + // The provided action doesn't map to a private transfer + INVALID_ACTION = 3; + } + Status status = 2; + enum Status { + UNKNOWN = 0; + // The action for the temporary private transfer was submitted and + // finalized. The opportunity to upgrade was missed. + TEMPORARY_ACTION_FINALIZED = 1; + // The next block of private transfers hasn't been created, so there + // is no proof available. Wait and try again later. + WAITING_FOR_NEXT_BLOCK = 2; + // The action can be upgraded to permanent privacy + READY_FOR_UPGRADE = 3; + // The action has already been upgraded + ALREADY_UPGRADED = 4; + } +} +message GetPrioritizedIntentsForPrivacyUpgradeRequest { + // The owner account to query against for upgradeable intents. + common.v1.SolanaAccountId owner = 1; + // The maximum number of intents to return in the response. Default is 10. + uint32 limit = 2; + // The signature is of serialize(GetPrioritizedIntentsForPrivacyUpgradeRequest) + // without this field set using the private key of the owner account. This + // provides an authentication mechanism to the RPC. + common.v1.Signature signature = 3; +} +message GetPrioritizedIntentsForPrivacyUpgradeResponse { + Result result = 1; + enum Result { + OK = 0; + NOT_FOUND = 1; + } + // Ordered from highest to lowest priority + repeated UpgradeableIntent items = 2 ; +} +message GetLimitsRequest { + // The owner account whose limits will be calculated. Any other owner accounts + // linked with the same identity of the owner will also be applied. + common.v1.SolanaAccountId owner = 1; + // The signature is of serialize(GetLimitsRequest) without this field set + // using the private key of the owner account. This provides an authentication + // mechanism to the RPC. + common.v1.Signature signature = 2; + // All transactions starting at this time will be incorporated into the consumed + // limit calculation. Clients should set this to the start of the current day in + // the client's current time zone (because server has no knowledge of this atm). + google.protobuf.Timestamp consumed_since = 3; +} +message GetLimitsResponse { + Result result = 1; + enum Result { + OK = 0; + } + // Send limits keyed by currency + map send_limits_by_currency = 2; + // Deposit limits + DepositLimit deposit_limit = 3; + // Micro payment limits keyed by currency + map micro_payment_limits_by_currency = 4; + // Buy module limits keyed by currency + map buy_module_limits_by_currency = 5; +} +message CanWithdrawToAccountRequest { + common.v1.SolanaAccountId account = 1; +} +message CanWithdrawToAccountResponse { + // Metadata so the client knows how to withdraw to the account. Server cannot + // provide precalculated addresses in this response to maintain non-custodial + // status. + AccountType account_type = 2; + enum AccountType { + Unknown = 0; // Server cannot determine + TokenAccount = 1; // Client uses the address as is in SubmitIntent + OwnerAccount = 2; // Client locally derives the ATA to use in SubmitIntent + } + // Server-controlled flag to indicate if the account can be withdrawn to. + // There are several reasons server may deny it, including: + // - Wrong type of Code account + // - Not wanting to subsidize the creation of an ATA + // - Unsupported external account type (eg. token account but of the wrong mint) + // This is guaranteed to be false when account_type = Unknown. + bool is_valid_payment_destination = 1; + // Token account requires initialization before the withdrawal can occur. + // Server has chosen not to subsidize the fees. The response is guaranteed + // to have set is_valid_payment_destination = false in this case. + bool requires_initialization = 3; +} +message AirdropRequest { + // The type of airdrop to claim + AirdropType airdrop_type = 1 ; + // The owner account to airdrop Kin to + common.v1.SolanaAccountId owner = 2; + // The signature is of serialize(AirdropRequest) without this field set + // using the private key of the owner account. This provides an authentication + // mechanism to the RPC. + common.v1.Signature signature = 3; +} +message AirdropResponse { + Result result = 1; + enum Result { + OK = 0; + // Airdrops are unavailable + UNAVAILABLE = 1; + // The airdrop has already been claimed by the owner + ALREADY_CLAIMED = 2; + } + // Exchange data for the amount of Kin airdropped when successful + ExchangeData exchange_data = 2; +} +message SwapRequest { + oneof request { + Initiate initiate = 1; + SubmitSignature submit_signature = 2; + } + message Initiate { + // The verified owner account public key + common.v1.SolanaAccountId owner = 1; + // The user authority account that will sign to authorize the swap. Ideally, + // this is an account derived off the owner account that is solely responsible + // for swapping. + common.v1.SolanaAccountId swap_authority = 2; + // Maximum amount to swap from the source mint, in quarks. If value is set to zero, + // the entire amount will be swapped. + uint64 limit = 3; + // Whether the client wants the RPC to wait for blockchain status. If false, + // then the RPC will return Success when the swap is submitted to the blockchain. + // Otherwise, the RPC will observe and report back the status of the transaction. + bool wait_for_blockchain_status = 4; + // The signature is of serialize(Initiate) without this field set using the + // private key of the owner account. This provides an authentication mechanism + // to the RPC. + common.v1.Signature signature = 5; + } + message SubmitSignature { + // The signature for the locally constructed swap transaction + common.v1.Signature signature = 1; + } +} +message SwapResponse { + oneof response { + ServerParameters server_parameters = 1; + Success success = 2; + Error error = 3; + } + message ServerParameters { + // Subisdizer account that will be paying for the swap + common.v1.SolanaAccountId payer = 1; + // Recent blockhash + common.v1.Blockhash recent_blockhash = 2; + // Compute unit limit provided to the ComputeBudget::SetComputeUnitLimit + // instruction. If the value is 0, then the instruction can be omitted. + uint32 compute_unit_limit = 3; + // Compute unit price provided in the ComputeBudget::SetComputeUnitPrice + // instruction. If the value is 0, then the instruction can be omitted. + uint64 compute_unit_price = 4; + // On-chain program that will be performing the swap + common.v1.SolanaAccountId swap_program = 5; + // Accounts provided to the swap instruction + repeated common.v1.InstructionAccount swap_ixn_accounts = 6 ; + // Instruction data for the swap instruction + bytes swap_ixn_data = 7 ; + // Maximum quarks that will be sent out of the source account after + // executing the swap. If not, the validation instruction will cause + // the transaction to fail. + uint64 max_to_send = 8; + // Minimum quarks that will be received into the destination account + // after executing the swap. If not, the validation instruction will + // cause the transaction to fail. + uint64 min_to_receive = 9; + // Nonce to use in swap validator state account PDA + common.v1.SolanaAccountId nonce = 10; + } + message Success { + Code code = 1; + enum Code { + // The swap was submitted to the blockchain. + SWAP_SUBMITTED = 0; + // The swap was finalized on the blockchain. + SWAP_FINALIZED = 1; + } + } + message Error { + Code code = 1; + enum Code { + // Denied by a guard (spam, money laundering, etc) + DENIED = 0; + // There is an issue with the provided signature. + SIGNATURE_ERROR = 2; + // The swap failed server-side validation + INVALID_SWAP = 3; + // The submitted swap transaction failed. Attempt the swap again. + SWAP_FAILED = 4; + } + repeated ErrorDetails error_details = 2; + } +} +message DeclareFiatOnrampPurchaseAttemptRequest { + // The owner account invoking the buy module + common.v1.SolanaAccountId owner = 1; + // The amount being purchased + ExchangeDataWithoutRate purchase_amount = 2; + // A nonce value unique to the purchase. If it's included in a memo for the + // transaction for the deposit to the owner, then purchase_amount will be used + // for display values. Otherwise, the amount will be inferred from the transaction. + common.v1.UUID nonce = 3; + // The signature is of serialize(DeclareFiatOnrampPurchaseAttemptRequest) without + // this field set using the private key of the owner account. This provides an + // authentication mechanism to the RPC. + common.v1.Signature signature = 4; +} +message DeclareFiatOnrampPurchaseAttemptResponse { + Result result = 1; + enum Result { + OK = 0; + // The owner account is not valid (ie. it isn't a Code account) + INVALID_OWNER = 1; + // The currency isn't supported + UNSUPPORTED_CURRENCY = 2; + // The amount specified exceeds limits + AMOUNT_EXCEEDS_MAXIMUM = 3; + } +} +// +// Metadata definitions +// +// Metadata describes the high-level details of an intent +message Metadata { + oneof type { + OpenAccountsMetadata open_accounts = 1; + SendPrivatePaymentMetadata send_private_payment = 2; + ReceivePaymentsPrivatelyMetadata receive_payments_privately = 3; + UpgradePrivacyMetadata upgrade_privacy = 4; + SendPublicPaymentMetadata send_public_payment = 6; + ReceivePaymentsPubliclyMetadata receive_payments_publicly = 7; + EstablishRelationshipMetadata establish_relationship = 8; + } + reserved 5; // Deprecated MigrateToPrivacy2022Metadata +} +// ExtendedPaymentMetadata is additional metadata that can be used for custom +// payment types +message ExtendedPaymentMetadata { + google.protobuf.Any value = 1; +; +} +// Open a set of accounts. Currently, clients should only use this for new users +// to open all required accounts up front (buckets, incoming, and outgoing). +// +// Action Spec: +// +// for account in [PRIMARY, TEMPORARY_INCOMING, TEMPORARY_OUTGOING, BUCKET_1_KIN, ... , BUCKET_1_000_000_KIN] +// actions.push_back(OpenAccountAction(account)) +message OpenAccountsMetadata { + // Nothing is currently required +} +// Sends a payment to a destination account with initial temporary privacy. Clients +// should also reorganize their bucket accounts and rotate their temporary outgoing +// account. +// +// Action Spec (In Person Cash Payment or Withdrawal or Tip): +// +// actions = [ +// // Section 1: Transfer ExchangeData.Quarks from BUCKET_X_KIN accounts to TEMPORARY_OUTGOING account with reogranizations +// +// TemporaryPrivacyExchangeAction(BUCKET_X_KIN, BUCKET_X_KIN, multiple * bucketSize), +// TemporaryPrivacyTransferAction(BUCKET_X_KIN, TEMPORARY_OUTGOING[index], multiple * bucketSize), +// ..., +// TemporaryPrivacyExchangeAction(BUCKET_X_KIN, BUCKET_X_KIN, multiple * bucketSize), +// TemporaryPrivacyTransferAction(BUCKET_X_KIN, TEMPORARY_OUTGOING[index], multiple * bucketSize), +// +// // Section 2: Rotate TEMPORARY_OUTGOING account +// +// // Below must appear last in this exact order +// NoPrivacyWithdrawAction(TEMPORARY_OUTGOING[index], destination, ExchangeData.Quarks), +// OpenAccountAction(TEMPORARY_OUTGOING[index + 1]), +// ] +// +// Action Spec (Remote Send): +// +// actions = [ +// // Section 1: Open REMOTE_SEND_GIFT_CARD account +// +// OpenAccountAction(REMOTE_SEND_GIFT_CARD), +// +// // Section 2: Transfer ExchangeData.Quarks from BUCKET_X_KIN accounts to TEMPORARY_OUTGOING account with reogranizations +// +// TemporaryPrivacyExchangeAction(BUCKET_X_KIN, BUCKET_X_KIN, multiple * bucketSize), +// TemporaryPrivacyTransferAction(BUCKET_X_KIN, TEMPORARY_OUTGOING[index], multiple * bucketSize), +// ..., +// TemporaryPrivacyExchangeAction(BUCKET_X_KIN, BUCKET_X_KIN, multiple * bucketSize), +// TemporaryPrivacyTransferAction(BUCKET_X_KIN, TEMPORARY_OUTGOING[index], multiple * bucketSize), +// +// // Section 3: Rotate TEMPORARY_OUTGOING account +// +// // Below must appear last in this exact order +// NoPrivacyWithdrawAction(TEMPORARY_OUTGOING[index], REMOTE_SEND_GIFT_CARD, ExchangeData.Quarks), +// OpenAccountAction(TEMPORARY_OUTGOING[index + 1]), +// +// // Section 4: Close REMOTE_SEND_GIFT_CARD if not redeemed after period of time +// +// todo: We need a new mechanism that doesn't rely on the deprecated CloseDormantAccountAction +// +// Action Spec (Micro Payment): +// +// actions = [ +// // Section 1: Transfer ExchangeData.Quarks from BUCKET_X_KIN accounts to TEMPORARY_OUTGOING account with reogranizations +// +// TemporaryPrivacyExchangeAction(BUCKET_X_KIN, BUCKET_X_KIN, multiple * bucketSize), +// TemporaryPrivacyTransferAction(BUCKET_X_KIN, TEMPORARY_OUTGOING[index], multiple * bucketSize), +// ..., +// TemporaryPrivacyExchangeAction(BUCKET_X_KIN, BUCKET_X_KIN, multiple * bucketSize), +// TemporaryPrivacyTransferAction(BUCKET_X_KIN, TEMPORARY_OUTGOING[index], multiple * bucketSize), +// +// // Section 2: Fee payments +// +// // Hard-coded Code $0.01 USD fee to a dynamic fee account +// FeePayment(TEMPORARY_OUTGOING[index], codeFeeAccount, $0.01 USD of Kin), +// +// // Additional fees, exactly as specified in the original payment request +// FeePayment(TEMPORARY_OUTGOING[index], additionalFeeAccount0, additionalFeeQuarks0), +// ... +// FeePayment(TEMPORARY_OUTGOING[index], additionalFeeAccountN, additionalFeeQuarksN), +// +// // Section 3: Rotate TEMPORARY_OUTGOING account +// +// // Below must appear last in this exact order +// NoPrivacyWithdrawAction(TEMPORARY_OUTGOING[index], destination, ExchangeData.Quarks - $0.01 USD of Kin - additionalFeeQuarks0 - ... - additionalFeeQuarksN), +// OpenAccountAction(TEMPORARY_OUTGOING[index + 1]), +// ] +message SendPrivatePaymentMetadata { + // The destination token account to send funds to + common.v1.SolanaAccountId destination = 1; + // The exchange data of total funds being sent to the destination + ExchangeData exchange_data = 2; + // Is the payment a withdrawal? For destinations that are not Code temporary + // accounts, this must be set to true. + bool is_withdrawal = 3; + // Is the payment for a remote send? + bool is_remote_send = 4; + // Is the payment for a tip? + bool is_tip = 5; + // If is_tip is true, the user being tipped + TippedUser tipped_user = 6; +} +// Send a payment to a destination account publicly. +// +// Action Spec: +// +// source = PRIMARY or RELATIONSHIP +// actions = [NoPrivacyTransferAction(source, destination, ExchangeData.Quarks)] +message SendPublicPaymentMetadata { + // The primary or relatinship account where funds will be sent from. The primary + // account is assumed if this field is not set for backwards compatibility with + // old clients. + common.v1.SolanaAccountId source = 4; + // The destination token account to send funds to. This cannot be a Code + // temporary account. + common.v1.SolanaAccountId destination = 1; + // The exchange data of total funds being sent to the destination + ExchangeData exchange_data = 2; + // Is the payment a withdrawal? Currently, this is always true. + bool is_withdrawal = 3; + ExtendedPaymentMetadata extended_metadata = 5; +} +// Receive funds into an organizer with initial temporary privacy. Clients should +// also reorganize their bucket accounts and rotate their temporary incoming account +// as applicable. Only accounts owned and derived by a user's 12 words should operate +// as a source in this intent type to guarantee privacy upgradeability. +// +// Action Spec (Payment): +// +// actions = [ +// // Section 1: Transfer Quarks from TEMPORARY_INCOMING account to BUCKET_X_KIN accounts with reorganizations +// +// TemporaryPrivacyTransferAction(TEMPORARY_INCOMING[index], BUCKET_X_KIN, multiple * bucketSize), +// TemporaryPrivacyExchangeAction(BUCKET_X_KIN, BUCKET_X_KIN, multiple * bucketSize), +// ..., +// TemporaryPrivacyTransferAction(TEMPORARY_INCOMING[index], BUCKET_X_KIN, multiple * bucketSize), +// TemporaryPrivacyExchangeAction(BUCKET_X_KIN, BUCKET_X_KIN, multiple * bucketSize), +// +// // Section 2: Rotate TEMPORARY_INCOMING account +// +// // Below must appear last in this exact order +// OpenAccountAction(TEMPORARY_INCOMING[index + 1]) +// ] +// +// Action Spec (Deposit): +// +// source = PRIMARY or RELATIONSHIP +// actions = [ +// TemporaryPrivacyTransferAction(source, BUCKET_X_KIN, multiple * bucketSize), +// TemporaryPrivacyExchangeAction(BUCKET_X_KIN, BUCKET_X_KIN, multiple * bucketSize), +// ..., +// TemporaryPrivacyTransferAction(source, BUCKET_X_KIN, multiple * bucketSize), +// TemporaryPrivacyExchangeAction(BUCKET_X_KIN, BUCKET_X_KIN, multiple * bucketSize), +// ] +message ReceivePaymentsPrivatelyMetadata { + // The temporary incoming, primary or relationship account to receive funds from + common.v1.SolanaAccountId source = 1; + // The exact amount of Kin in quarks being received + uint64 quarks = 2; + // Is the receipt of funds from a deposit? If true, the source account must + // be a primary or relationship account. Otherwise, it must be from a temporary + // incoming account. + bool is_deposit = 3; +} +// Receive funds into a user-owned account publicly. All use cases of this intent +// close the account, so all funds must be moved. Use this intent to receive payments +// from an account not owned by a user's 12 words into a temporary incoming account, +// which will guarantee privacy upgradeability. +// +// Action Spec (Remote Send): +// +// actions = [NoPrivacyWithdrawAction(REMOTE_SEND_GIFT_CARD, TEMPORARY_INCOMING[latest_index], quarks)] +message ReceivePaymentsPubliclyMetadata { + // The remote send gift card to receive funds from + common.v1.SolanaAccountId source = 1; + // The exact amount of Kin in quarks being received + uint64 quarks = 2; + // Is the receipt of funds from a remote send gift card? Currently, this is + // the only use case for this intent and validation enforces the flag to true. + bool is_remote_send = 3; + // If is_remote_send is true, is the gift card being voided? The user owner + // account's 12 words that issued the gift card may only set this flag to true. + // Functionally, this doesn't affect the intent, but rather if we decide to show + // it in a user-friendly payment history. + bool is_issuer_voiding_gift_card = 4; + // If is_remote_send is true, the original exchange data that was provided as + // part of creating the gift card account. This is purely a server-provided value. + // SubmitIntent will disallow this being set. + ExchangeData exchange_data = 5; +} +// Upgrade existing private virtual instructions from temporary to permanent privacy. +message UpgradePrivacyMetadata { + // Nothing is currently required +} +// Establishes a long-lived private relationship between a user and another +// entity. +// +// Prereqs: +// - OpenAccounts intent has been submitted +// +// Action spec: +// +// actions = [OpenAccountAction(RELATIONSHIP)] +message EstablishRelationshipMetadata { + common.v1.Relationship relationship = 1; +} +// +// Action Definitions +// +// Action is a well-defined, ordered and small set of transactions or virtual instructions +// for a unit of work that the client wants to perform on the blockchain. Clients provide +// parameters known to them in the action. +message Action { + // The ID of this action, which is unique within an intent. It must match + // the index of the action's location in the SubmitAction's actions field. + uint32 id = 1; + // The type of action to perform. + oneof type { + OpenAccountAction open_account = 2; + NoPrivacyTransferAction no_privacy_transfer = 5; + NoPrivacyWithdrawAction no_privacy_withdraw = 6; + TemporaryPrivacyTransferAction temporary_privacy_transfer = 7; + TemporaryPrivacyExchangeAction temporary_privacy_exchange = 8; + PermanentPrivacyUpgradeAction permanent_privacy_upgrade = 9; + FeePaymentAction fee_payment = 10; + } + reserved 3; // Deprecated CloseEmptyAccountAction + reserved 4; // Deprecated CloseDormantAccountAction +} +// Virtual Instruction 1 +// Instructions: +// 1. system::AdvanceNonce +// 2. cvm::SystemTimelockInit +// Client Signature Required: No +message OpenAccountAction { + // The type of account, which will dictate its intended use + common.v1.AccountType account_type = 1; + // The owner of the account. For accounts liked to a user's 12 words, this is + // the verified parent owner account public key. All other account types should + // set this to the authority value. + common.v1.SolanaAccountId owner = 2; + // The index used to for accounts that are derived from owner + uint64 index = 3; + // The public key of the private key that has authority over the opened token account + common.v1.SolanaAccountId authority = 4; + // The token account being opened + common.v1.SolanaAccountId token = 5; + // The signature is of serialize(OpenAccountAction) without this field set + // using the private key of the authority account. This provides a proof + // of authorization to link authority to owner. + common.v1.Signature authority_signature = 6; +} +// Virtual Instruction 1 +// Instructions: +// 1. system::AdvanceNonce +// 2. memo::Memo +// 3. timelock::TransferWithAuthority (source -> destination) +// Client Signature Required: Yes +message NoPrivacyTransferAction { + // The public key of the private key that has authority over source + common.v1.SolanaAccountId authority = 1; + // The source account where funds are transferred from + common.v1.SolanaAccountId source = 2; + // The destination account where funds are transferred to + common.v1.SolanaAccountId destination = 3; + // The Kin quark amount to transfer + uint64 amount = 4; +} +// Virtual Instruction 1 +// Instructions: +// 1. system::AdvanceNonce +// 2. memo::Memo +// 3. timelock::RevokeLockWithAuthority +// 4. timelock::DeactivateLock +// 5. timelock::Withdraw (source -> destination) +// 6. timelock::CloseAccounts +// Client Signature Required: Yes +message NoPrivacyWithdrawAction { + // The public key of the private key that has authority over source + common.v1.SolanaAccountId authority = 1; + // The source account where funds are transferred from + common.v1.SolanaAccountId source = 2; + // The destination account where funds are transferred to + common.v1.SolanaAccountId destination = 3; + // The intended Kin quark amount to withdraw + uint64 amount = 4; + + // Whether the account is closed afterwards. This is always true, since there + // are no current se cases to leave it open. + bool should_close = 5; +} +// Virtual Instruction 1 +// Instructions: +// 1. system::AdvanceNonce +// 2. memo::Memo +// 3. splitter::TransferWithCommitment (treasury -> destination) +// Client Signature Required: No +// +// Virtual Instruction 2 +// Instructions: +// 1. system::AdvanceNonce +// 2. memo::Memo +// 3. timelock::TransferWithAuthority (source -> commitment) +// Client Signature Required: Yes +message TemporaryPrivacyTransferAction { + // The public key of the private key that has authority over source + common.v1.SolanaAccountId authority = 1; + // The source account where funds are transferred from + common.v1.SolanaAccountId source = 2; + // The destination account where funds are transferred to + common.v1.SolanaAccountId destination = 3; + // The Kin quark amount to transfer + uint64 amount = 4; +} +// Virtual Instruction 1 +// Instructions: +// 1. system::AdvanceNonce +// 2. memo::Memo +// 3. splitter::TransferWithCommitment (treasury -> destination) +// Client Signature Required: No +// +// Virtual Instruction 2 +// Instructions: +// 1. system::AdvanceNonce +// 2. memo::Memo +// 3. timelock::TransferWithAuthority (source -> commitment) +// Client Signature Required: Yes +message TemporaryPrivacyExchangeAction { + // The public key of the private key that has authority over source + common.v1.SolanaAccountId authority = 1; + // The source account where funds are exchanged from + common.v1.SolanaAccountId source = 2; + // The destination account where funds are exchanged to + common.v1.SolanaAccountId destination = 3; + // The Kin quark amount to exchange + uint64 amount = 4; +} +// Virtual Instruction 1 +// Instructions: +// 1. system::AdvanceNonce +// 2. memo::Memo +// 3. timelock::TransferWithAuthority (source -> different commitment) +// Client Signature Required: Yes +message PermanentPrivacyUpgradeAction { + // The action ID of the temporary private transfer or exchange to upgrade + uint32 action_id = 1; +} +// Virtual Instruction 1 +// Instructions: +// 1. system::AdvanceNonce +// 2. memo::Memo +// 3. timelock::TransferWithAuthority (source -> fee account) +// Client Signature Required: Yes +// +// Note: This is exactly a NoPrivacyTransferAction, but with specialized metadata +// for fees. +message FeePaymentAction { + // The type of fee being operated on + FeeType type = 4; + enum FeeType { + CODE = 0; // Hardcoded $0.01 USD fee to a dynamic fee account specified by server + THIRD_PARTY = 1; // Third party fee specified at time of payment request + } + // The public key of the private key that has authority over source + common.v1.SolanaAccountId authority = 1; + // The source account where funds are transferred from + common.v1.SolanaAccountId source = 2; + // The Kin quark amount to transfer + uint64 amount = 3; + // The destination where the fee payment is being made for fees outside of + // Code. + common.v1.SolanaAccountId destination = 5; +} +// +// Server Parameter Definitions +// +// ServerParameter are a set of parameters known and returned by server that +// enables clients to complete transaction construction. Any necessary proofs, +// which are required to be locally verifiable, are also provided to ensure +// safe use in the event of a malicious server. +message ServerParameter { + // The action the server parameters belong to + uint32 action_id = 1; + // The set of nonces used for the action. Server will only provide values + // for transactions requiring client signatures. + repeated NoncedTransactionMetadata nonces = 2 ; + // The type of server parameter which maps to the type of action requested + oneof type { + OpenAccountServerParameter open_account = 3; + NoPrivacyTransferServerParameter no_privacy_transfer = 6; + NoPrivacyWithdrawServerParameter no_privacy_withdraw = 7; + TemporaryPrivacyTransferServerParameter temporary_privacy_transfer = 8; + TemporaryPrivacyExchangeServerParameter temporary_privacy_exchange = 9; + PermanentPrivacyUpgradeServerParameter permanent_privacy_upgrade = 10; + FeePaymentServerParameter fee_payment = 11; + } + reserved 4; // Deprecated CloseEmptyAccountServerParameter + reserved 5; // Deprecated CloseDormantAccountServerParameter +} +// For transactions, the nonce is a standard nonce on Solana +// For virtual instructions, the nonce is a virtual nonce on the Code VM +message NoncedTransactionMetadata { + // The nonce account to use in the system::AdvanceNonce instruction + common.v1.SolanaAccountId nonce = 1; + // The blockhash to set in the transaction or virtual instruction + common.v1.Blockhash blockhash = 2; +} +message OpenAccountServerParameter { + // There are no transactions requiring client signatures +} +message NoPrivacyTransferServerParameter { + // There are no action-specific server parameters +} +message NoPrivacyWithdrawServerParameter { + // There are no action-specific server parameters +} +message TemporaryPrivacyTransferServerParameter { + // The treasury that will be used to split payments and provide a level of privacy + common.v1.SolanaAccountId treasury = 1; + // A recent root server observed from the treasury + common.v1.Hash recent_root = 2; +} +message TemporaryPrivacyExchangeServerParameter { + // The treasury that will be used to split payments and provide a level of privacy + common.v1.SolanaAccountId treasury = 1; + // A recent root server observed from the treasury + common.v1.Hash recent_root = 2; +} +message PermanentPrivacyUpgradeServerParameter { + // The new commitment that is being paid + common.v1.SolanaAccountId new_commitment = 1; + // The new commitment account's transcript. This is purely needed by client + // to validate merkle_root with commitment PDA logic. + common.v1.Hash new_commitment_transcript = 2; + // The new commitment account's destination. This is purely needed by client + // to validate merkle_root with commitment PDA logic. + common.v1.SolanaAccountId new_commitment_destination = 3; + // The new commitment account's payment amount. This is purely needed by client + // to validate merkle_root with commitment PDA logic. + uint64 new_commitment_amount = 4; + // The merkle root, which was the recent root used in the new commitment account + common.v1.Hash merkle_root = 5; + // The merkle proof that validates the original commitment occurred prior to + // the new commitment server is asking client to pay + repeated common.v1.Hash merkle_proof = 6 ; +} +message FeePaymentServerParameter { + // The destination account where Code fee payments should be sent. This will + // only be set when the corresponding FeePaymentAction Type is CODE. + common.v1.SolanaAccountId code_destination = 1; +} +// +// Structured Error Definitions +// +message ErrorDetails { + oneof type { + ReasonStringErrorDetails reason_string = 1; + InvalidSignatureErrorDetails invalid_signature = 2; + DeniedErrorDetails denied = 3; + } +} +message ReasonStringErrorDetails { + // Human readable string indicating the failure. + string reason = 1 ; +} +message InvalidSignatureErrorDetails { + // The action whose signature mismatched + uint32 action_id = 1; + oneof expected_blob { + // The transaction the server expected to have signed. + common.v1.Transaction expected_transaction = 2; + // The virtual ixn hash the server expected to have signed. + common.v1.Hash expected_vixn_hash = 4; + } + // The signature that was provided by the client. + common.v1.Signature provided_signature = 3; +} +message DeniedErrorDetails { + Code code = 1; + enum Code { + // Reason code not yet defined + UNSPECIFIED = 0; + // Phone number has exceeded its free account allocation + TOO_MANY_FREE_ACCOUNTS_FOR_PHONE_NUMBER = 1; + // Device has exceeded its free account allocation + TOO_MANY_FREE_ACCOUNTS_FOR_DEVICE = 2; + // The country associated with the phone number with the account is not + // supported (eg. it is on the sanctioned list). + UNSUPPORTED_COUNTRY = 3; + // The device is not supported (eg. it fails device attestation checks) + UNSUPPORTED_DEVICE = 4; + } + // Human readable string indicating the failure. + string reason = 2 ; +} +// +// Other Model Definitions +// +// UpgradeableIntent is an intent whose actions can be upgraded. +message UpgradeableIntent { + // The intent ID + common.v1.IntentId id = 1; + // The set of private actions that can be upgraded + repeated UpgradeablePrivateAction actions = 2 ; + message UpgradeablePrivateAction { + // The blob that was hashed and signed by the client for a virtual instruction. + // Clients *MUST* use the source and destination accounts in the timelock::TransferWithAuthority + // instruction to validate all fields provided by server by locally computing the expected + // addresses. + common.v1.Transaction transaction_blob = 1; + // The client's signature for the virtual instruction. Clients MUST use this to + // locally validate the virtual instruction blob provided by server. + common.v1.Signature client_signature = 2; + // The action ID of this virtual instruction + uint32 action_id = 3; + // The source account's type, which hints how to efficiently derive source + common.v1.AccountType source_account_type = 4; +; + // The source account's derivation index, which hints how to efficiently derive source + uint64 source_derivation_index = 5; + // The original destination account that was paid by the treasury + common.v1.SolanaAccountId original_destination = 6; + // The original quark amount for the action + uint64 original_amount = 7; + // The treasury used for this the private action + common.v1.SolanaAccountId treasury = 8; + // The recent root observed at the time of intent creation for this private action + common.v1.Hash recent_root = 9; + } +} +// ExchangeData defines an amount of Kin with currency exchange data +message ExchangeData { + // ISO 4217 alpha-3 currency code. + string currency = 1; + // The agreed upon exchange rate. This might not be the same as the + // actual exchange rate at the time of intent or fund transfer. + double exchange_rate = 2; + // The agreed upon transfer amount in the currency the payment was made + // in. + double native_amount = 3; + // The exact amount of quarks to send. This will be used as the source of + // truth for validating transaction transfer amounts. + uint64 quarks = 4; +} +message ExchangeDataWithoutRate { + // ISO 4217 alpha-3 currency code. + string currency = 1; + // The agreed upon transfer amount in the currency the payment was made + // in. + double native_amount = 2; +} +message AdditionalFeePayment { + // Destination Kin token account where the fee payment will be made + common.v1.SolanaAccountId destination = 1; + // Fee percentage, in basis points, of the total quark amount of a payment. + uint32 fee_bps = 2 ; +} +message SendLimit { + // Remaining limit to apply on the next transaction + float next_transaction = 1; + // Maximum allowed on a per-transaction basis + float max_per_transaction = 2; + // Maximum allowed on a per-day basis + float max_per_day = 3; +} +message DepositLimit { + // Maximum quarks that may be deposited at any time. Server will guarantee + // this threshold will be below enforced dollar value limits, while also + // ensuring sufficient funds are available for a full organizer that supports + // max payment sends. Total dollar value limits may be spread across many deposits. + uint64 max_quarks = 1; +} +message MicroPaymentLimit { + // Maximum native amount that can be applied per micro payment transaction + float max_per_transaction = 1; + // Minimum native amount that can be applied per micro payment transaction + float min_per_transaction = 2; +} +message BuyModuleLimit { + // Minimum amount that can be purchased through the buy module + float min_per_transaction = 1; + // Maximum amount that can be purchased through the buy module + float max_per_transaction = 2; +} +message TippedUser { + Platform platform = 1 ; + enum Platform { + UNKNOWN = 0; + TWITTER = 1; + } + string username = 2 ; +} +message Cursor { + bytes value = 1 ; +} +enum AirdropType { + UNKNOWN = 0; + // Reward for giving someone else their first Kin + GIVE_FIRST_KIN = 1; + // Airdrop for getting a user started with first Kin balance + GET_FIRST_KIN = 2; +} diff --git a/definitions/code-vm/protos/src/main/proto/user/v1/code_identity_service.proto b/definitions/code-vm/protos/src/main/proto/user/v1/code_identity_service.proto new file mode 100644 index 000000000..4c71a3ae1 --- /dev/null +++ b/definitions/code-vm/protos/src/main/proto/user/v1/code_identity_service.proto @@ -0,0 +1,294 @@ +syntax = "proto3"; +package code.user.v1; +option go_package = "github.com/code-payments/code-protobuf-api/generated/go/user/v1;user"; +option java_package = "com.codeinc.gen.user.v1"; +option objc_class_prefix = "CPBUserV1"; +import "common/v1/code_model.proto"; +import "phone/v1/code_phone_verification_service.proto"; +import "transaction/v2/code_transaction_service.proto"; + +service Identity { + // LinkAccount links an owner account to the user identified and authenticated + // by a one-time use token. + // + // Notably, this RPC has the following side effects: + // * A new user is automatically created if one doesn't exist. + // * Server will create a new data container for at least every unique + // owner account linked to the user. + rpc LinkAccount(LinkAccountRequest) returns (LinkAccountResponse); + // UnlinkAccount removes links from an owner account. It will NOT remove + // existing associations between users, owner accounts and identifying + // features. + // + // The following associations will remain intact to ensure owner accounts + // can continue to be used with a consistent login experience: + // * the user continues to be associated to existing owner accounts and + // identifying features + // + // Client can continue mainting their current login session. Their current + // user and data container will remain the same. + // + // The call is guaranteed to be idempotent. It will not fail if the link is + // already removed by either a previous call to this RPC or by a more recent + // call to LinkAccount. A failure will only occur if the link between a user + // and the owner accout or identifying feature never existed. + rpc UnlinkAccount(UnlinkAccountRequest) returns (UnlinkAccountResponse); + // GetUser gets user information given a user identifier and an owner account. + rpc GetUser(GetUserRequest) returns (GetUserResponse); + // UpdatePreferences updates user preferences. + rpc UpdatePreferences(UpdatePreferencesRequest) returns (UpdatePreferencesResponse); + // LoginToThirdPartyApp logs a user into a third party app for a given intent + // ID. If the original request requires payment, then SubmitIntent must be called. + rpc LoginToThirdPartyApp(LoginToThirdPartyAppRequest) returns (LoginToThirdPartyAppResponse); + // GetLoginForThirdPartyApp gets a login for a third party app from an existing + // request. This endpoint supports all paths where login is possible (login on payment, + // raw login, etc.). + rpc GetLoginForThirdPartyApp(GetLoginForThirdPartyAppRequest) returns (GetLoginForThirdPartyAppResponse); + // GetTwitterUser gets Twitter user information + // + // Note 1: This RPC will only return results for Twitter users that have + // accounts linked with Code. + // + // Note 2: This RPC is heavily cached, and may not reflect real-time Twitter + // information. + rpc GetTwitterUser(GetTwitterUserRequest) returns (GetTwitterUserResponse); +} +message LinkAccountRequest { + // The public key of the owner account that will be linked to a user. + common.v1.SolanaAccountId owner_account_id = 1; + // The signature is of serialize(LinkAccountRequest) without this field set + // using the private key of owner_account_id. This validates that the client + // actually owns the account. + common.v1.Signature signature = 2; + // A one-time use token that identifies and authenticates the user. + oneof token { + // A token received after successfully verifying a phone number via a + // SMS code using the phone verification service. + phone.v1.PhoneLinkingToken phone = 3; + } +} +message LinkAccountResponse { + Result result = 1; + enum Result { + OK = 0; + // The provided token is invalid. A token may be invalid for a number of + // reasons including: it's already been used, has been modified by the + // client or has expired. + INVALID_TOKEN = 1; + // The client is rate limited (eg. by IP, user ID, etc). The client should + // retry at a later time. + RATE_LIMITED = 2; + } + // The user that was linked to the owner account + User user = 2; + // The data container where the user can store a copy of their data + common.v1.DataContainerId data_container_id = 3; + // Field 4 is the deprecated kin_token_account_details + reserved 4; + // Metadata about the user based for the instance of their view + oneof metadata { + // Metadata that corresponds to a phone-based identifying feature. + PhoneMetadata phone = 5; + } +} +message UnlinkAccountRequest { + // The public key of the owner account that will be unliked. + common.v1.SolanaAccountId owner_account_id = 1; + // The signature is of serialize(UnlinkAccountRequest) without this field set + // using the private key of owner_account_id. This provides an authentication + // mechanism to the RPC. + common.v1.Signature signature = 2; + oneof identifying_feature { + // The phone number associated with the owner account. + common.v1.PhoneNumber phone_number = 4; + } +} +message UnlinkAccountResponse { + Result result = 1; + enum Result { + OK = 0; + // The client attempted to unlink an owner account or identifying feature + // that never had a valid association. + NEVER_ASSOCIATED = 1; + } +} +message GetUserRequest { + // The public key of the owner account that signed this request message. + common.v1.SolanaAccountId owner_account_id = 1; + // The signature is of serialize(GetUserRequest) without this field set + // using the private key of owner_account_id. This provides an authentication + // mechanism to the RPC. + common.v1.Signature signature = 2; + // The user's indentifying feature, which maps to an instance of a view. + oneof identifying_feature { + common.v1.PhoneNumber phone_number = 3; + } +} +message GetUserResponse { + Result result = 1; + enum Result { + OK = 0; + // The user doesn't exist + NOT_FOUND = 1; + // The user is no longer invited + NOT_INVITED = 2; + // The user exists, but at least one of their timelock accounts is unlocked + UNLOCKED_TIMELOCK_ACCOUNT = 3; + } + // The user associated with the identifier + User user = 2; + // The data container where the user can store a copy of their data + common.v1.DataContainerId data_container_id = 3; + // Field 4 is the deprecated kin_token_account_details + reserved 4; + // Metadata about the user based for the instance of their view + oneof metadata { + // Metadata that corresponds to a phone-based identifying feature. + PhoneMetadata phone = 5; + } + // Whether client internal flags are enabled for this user + bool enable_internal_flags = 6; + // Set of which airdrops the user is eligible to receive + repeated transaction.v2.AirdropType eligible_airdrops = 7; + // Wether the buy module is enabled for this user + bool enable_buy_module = 8; +} +message UpdatePreferencesRequest { + // The public key of the owner account that signed this request message. + common.v1.SolanaAccountId owner_account_id = 1; + // The data container for the copy of the contact list being added to. + common.v1.DataContainerId container_id = 2; + // The signature is of serialize(UpdatePreferencesRequest) without this field set + // using the private key of owner_account_id. + common.v1.Signature signature = 3; + // The user's locale, which is used for server-side localization of things like + // chat messages, pushes, etc. If no locale is set, or the provided locale isn't + // supported, then English is used as the default fallback. + // + // Note: This is required since it's the only preference. In the future, we'll add + // optional fields, where the subset of fields provided will be the ones that + // are updated. + common.v1.Locale locale = 4; +} +message UpdatePreferencesResponse { + Result result = 1; + enum Result { + OK = 0; + // The provided locale couldn't be parsed or recognized and is invalid. + INVALID_LOCALE = 1; + } +} +message LoginToThirdPartyAppRequest { + // The intent ID identifying the instance of the login flow. + common.v1.IntentId intent_id = 1; + // The relationship authority account logging in. + common.v1.SolanaAccountId user_id = 2; + // Signature of this message using the user private key, which authenticates + // the user. + common.v1.Signature signature = 3; +} +message LoginToThirdPartyAppResponse { + Result result = 1; + enum Result { + // This supports idempotency. The same login with the same user will result + // in OK. + OK = 0; + // There is no request for the provided intent ID. + REQUEST_NOT_FOUND = 1; + // The request requires a payment. Call SubmitIntent instead. + PAYMENT_REQUIRED = 2; + // The request exists, but doesn't support login. + LOGIN_NOT_SUPPORTED = 3; + // A login with a different user already exists + DIFFERENT_LOGIN_EXISTS = 4; + // The provided account is not valid for login. It must be a relationship + // account with the correct identifier specified in the original request. + INVALID_ACCOUNT = 5; + } +} +message GetLoginForThirdPartyAppRequest { + // The intent ID identifying the instance of the login flow. + common.v1.IntentId intent_id = 1; + // Owner account owned by the third party used in domain verification. + common.v1.SolanaAccountId verifier = 2; + // Signature of this message using the verifier private key, which in addition + // to domain verification, authenticates the third party. + common.v1.Signature signature = 3; +} +message GetLoginForThirdPartyAppResponse { + Result result = 1; + enum Result { + OK = 0; + // There is no request for the provided intent ID. + REQUEST_NOT_FOUND = 1; + // The request exists, but doesn't support login. + LOGIN_NOT_SUPPORTED = 2; + // The intent supports login, but it hasn't been submitted. There is no + // logged in user yet. + NO_USER_LOGGED_IN = 3; + } + // The relationship authority account that logged in. + common.v1.SolanaAccountId user_id = 2; +} +message GetTwitterUserRequest { + oneof query { + // The Twitter username to query against + string username = 1 ; + // The tip address to query against + common.v1.SolanaAccountId tip_address = 2; + } +} +message GetTwitterUserResponse { + Result result = 1; + enum Result { + OK = 0; + // The Twitter user doesn't exist or isn't linked with a Code account + NOT_FOUND = 1; + } + TwitterUser twitter_user = 2; +} +// User is the highest order of a form of identity within Code. +// +// Note: Users outside Code are modelled as relationship accounts +message User { + // The user's ID + common.v1.UserId id = 1; + // The identifying features that are associated with the user + View view = 2; +} +// View is a well-defined set of identifying features. It is contrained to having +// exactly one feature set at a time, for now. +message View { + // The phone number associated with a user. + // + // Note: This field is mandatory as of right now, since it's the only one + // supported to date. + common.v1.PhoneNumber phone_number = 1; +} +message PhoneMetadata { + // State that determines whether a phone number is linked to the owner + // account. A phone number is linked if we can treat it as an alias. + // This is notably different from association, which answers the question + // of whether the number was linked at any point in time. + bool is_linked = 1; +} +message TwitterUser { + // Public key for a token account where tips are routed + common.v1.SolanaAccountId tip_address = 1; + // The user's username on Twitter + string username = 2 ; + // The user's friendly name on Twitter + string name = 3 ; + // URL to the user's Twitter profile picture + string profile_pic_url = 4 ; + // The type of Twitter verification associated with the user + VerifiedType verified_type = 5; + enum VerifiedType { + NONE = 0; + BLUE = 1; + BUSINESS = 2; + GOVERNMENT = 3; + } + // The number of followers the user has on Twitter + uint32 follower_count = 6; +} diff --git a/service/protos/.gitignore b/definitions/code/models/.gitignore similarity index 100% rename from service/protos/.gitignore rename to definitions/code/models/.gitignore diff --git a/service/models/build.gradle.kts b/definitions/code/models/build.gradle.kts similarity index 94% rename from service/models/build.gradle.kts rename to definitions/code/models/build.gradle.kts index 4d69ebcd9..b8c3842e4 100644 --- a/service/models/build.gradle.kts +++ b/definitions/code/models/build.gradle.kts @@ -12,7 +12,7 @@ version = "0.0.1" group = "com.codeinc.gen" dependencies { - protobuf(project(":service:protos")) + protobuf(project(":definitions:code:protos")) implementation(Libs.grpc_protobuf_lite) implementation(Libs.grpc_stub) @@ -28,7 +28,7 @@ kotlin { } android { - namespace = "${Android.namespace}.service.models" + namespace = "${Android.codeNamespace}.service.models" compileSdk = Android.compileSdkVersion defaultConfig { minSdk = Android.minSdkVersion diff --git a/definitions/code/protos/.gitignore b/definitions/code/protos/.gitignore new file mode 100644 index 000000000..9f2a07880 --- /dev/null +++ b/definitions/code/protos/.gitignore @@ -0,0 +1,2 @@ +build/ +.gradle/ diff --git a/definitions/code/protos/build.gradle.kts b/definitions/code/protos/build.gradle.kts new file mode 100644 index 000000000..4cd4475f1 --- /dev/null +++ b/definitions/code/protos/build.gradle.kts @@ -0,0 +1,11 @@ +// todo: maybe use variants / configurations to do both stub & stub-lite here + +// Note: We use the java-library plugin to get the protos into the artifact for this subproject +// because there doesn't seem to be an better way. +plugins { + `java-library` +} + +java { + sourceSets.getByName("main").resources.srcDir("src/main/proto") +} \ No newline at end of file diff --git a/service/protos/src/main/proto/account/v1/account_service.proto b/definitions/code/protos/src/main/proto/account/v1/account_service.proto similarity index 100% rename from service/protos/src/main/proto/account/v1/account_service.proto rename to definitions/code/protos/src/main/proto/account/v1/account_service.proto diff --git a/service/protos/src/main/proto/badge/v1/badge_service.proto b/definitions/code/protos/src/main/proto/badge/v1/badge_service.proto similarity index 100% rename from service/protos/src/main/proto/badge/v1/badge_service.proto rename to definitions/code/protos/src/main/proto/badge/v1/badge_service.proto diff --git a/service/protos/src/main/proto/chat/v1/chat_service.proto b/definitions/code/protos/src/main/proto/chat/v1/chat_service.proto similarity index 100% rename from service/protos/src/main/proto/chat/v1/chat_service.proto rename to definitions/code/protos/src/main/proto/chat/v1/chat_service.proto diff --git a/definitions/code/protos/src/main/proto/chat/v2/chat_service.proto b/definitions/code/protos/src/main/proto/chat/v2/chat_service.proto new file mode 100644 index 000000000..9b5df3813 --- /dev/null +++ b/definitions/code/protos/src/main/proto/chat/v2/chat_service.proto @@ -0,0 +1,420 @@ +syntax = "proto3"; +package code.chat.v2; +option go_package = "github.com/code-payments/code-protobuf-api/generated/go/chat/v2;chat"; +option java_package = "com.codeinc.gen.chat.v2"; +option objc_class_prefix = "CPBChatV2"; +import "common/v1/model.proto"; +import "transaction/v2/transaction_service.proto"; +import "google/protobuf/timestamp.proto"; + +service Chat { + // GetChats gets the set of chats for an owner account using a paged API. + // This RPC is aware of all identities tied to the owner account. + rpc GetChats(GetChatsRequest) returns (GetChatsResponse); + // GetMessages gets the set of messages for a chat member using a paged API + rpc GetMessages(GetMessagesRequest) returns (GetMessagesResponse); + // StreamChatEvents streams chat events in real-time. Chat events include + // messages, pointer updates, etc. + // + // The streaming protocol is follows: + // 1. Client initiates a stream by sending an OpenChatEventStream message. + // 2. If an error is encoutered, a ChatStreamEventError message will be + // returned by server and the stream will be closed. + // 3. Server will immediately flush initial chat state. + // 4. New chat events will be pushed to the stream in real time as they + // are received. + // + // This RPC supports a keepalive protocol as follows: + // 1. Client initiates a stream by sending an OpenChatEventStream message. + // 2. Upon stream initialization, server begins the keepalive protocol. + // 3. Server sends a ping to the client. + // 4. Client responds with a pong as fast as possible, making note of + // the delay for when to expect the next ping. + // 5. Steps 3 and 4 are repeated until the stream is explicitly terminated + // or is deemed to be unhealthy. + // + // Client notes: + // * Client should be careful to process events async, so any responses to pings are + // not delayed. + // * Clients should implement a reasonable backoff strategy upon continued timeout + // failures. + // * Clients that abuse pong messages may have their streams terminated by server. + // + // At any point in the stream, server will respond with events in real time as + // they are observed. Events sent over the stream should not affect the ping/pong + // protocol timings. + rpc StreamChatEvents(stream StreamChatEventsRequest) returns (stream StreamChatEventsResponse); + // StartChat starts a chat. The RPC call is idempotent and will use existing + // chats whenever applicable within the context of message routing. + rpc StartChat(StartChatRequest) returns (StartChatResponse); + // SendMessage sends a message to a chat. + rpc SendMessage(SendMessageRequest) returns (SendMessageResponse); + // AdvancePointer advances a pointer in message history for a chat member. + rpc AdvancePointer(AdvancePointerRequest) returns (AdvancePointerResponse); + // SetMuteState configures a chat member's mute state. + rpc SetMuteState(SetMuteStateRequest) returns (SetMuteStateResponse); + // NotifyIsTypingRequest notifies a chat that the sending member is typing. + // + // These requests are transient, and may be dropped at any point. + rpc NotifyIsTyping(NotifyIsTypingRequest) returns (NotifyIsTypingResponse); +} +message GetChatsRequest { + common.v1.SolanaAccountId owner = 1; + common.v1.Signature signature = 2; + uint32 page_size = 3; + Cursor cursor = 4; + Direction direction = 5; + enum Direction { + ASC = 0; + DESC = 1; + } +} +message GetChatsResponse { + Result result = 1; + enum Result { + OK = 0; + } + repeated Metadata chats = 2 ; +} +message GetMessagesRequest { + common.v1.ChatId chat_id = 1; + common.v1.SolanaAccountId owner = 2; + common.v1.Signature signature = 3; + uint32 page_size = 4; + Cursor cursor = 5; + Direction direction = 6; + enum Direction { + ASC = 0; + DESC = 1; + } +} +message GetMessagesResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + } + repeated Message messages = 2 ; +} +message OpenChatEventStream { + common.v1.ChatId chat_id = 1; + common.v1.SolanaAccountId owner = 2; + common.v1.Signature signature = 3; +} +message ChatStreamEvent { + oneof type { + Message message = 1; + Pointer pointer = 2; + IsTyping is_typing = 3; + } +} +message ChatStreamEventBatch { + repeated ChatStreamEvent events = 2 ; +} +message ChatStreamEventError { + Code code = 1; + enum Code { + DENIED = 0; + } +} +message StreamChatEventsRequest { + oneof type { + OpenChatEventStream open_stream = 1; + common.v1.ClientPong pong = 2; + } +} +message StreamChatEventsResponse { + oneof type { + ChatStreamEventBatch events = 1; + common.v1.ServerPing ping = 2; + ChatStreamEventError error = 3; + } +} +message StartChatRequest { + common.v1.SolanaAccountId owner = 1; + common.v1.Signature signature = 2; + oneof parameters { + StartTwoWayChatParameters two_way_chat = 3; + } +} +// StartTwoWayChatParameters contains the parameters required to start +// or recover a two way chat between the caller and the specified 'other_user'. +// +// The 'other_user' is currently the 'tip_address', normally retrieved from +// user.Identity.GetTwitterUser(username). +message StartTwoWayChatParameters { + // The account id of the user the caller wishes to chat with. + // + // This will be the `tip` (or equivalent) address. + common.v1.SolanaAccountId other_user = 1; + // The intent_id of the payment that initiated the chat/friendship. + // + // This field is optional. It is used as an optimization when the server has not + // yet observed the establishment of a friendship. In this case, the server will + // use the provided intent_id to verify the friendship. + // + // This is most likely to occur when initiating a chat with a user for the first + // time. + common.v1.IntentId intent_id = 2; +} +message StartChatResponse { + Result result = 1; + enum Result { + OK = 0; + // DENIED indicates the caller is not allowed to start/join the chat. + DENIED = 1; + // INVALID_PRAMETER indicates one of the parameters is invalid. + INVALID_PARAMETER = 2; + // PENDING indicates that the payment (for chat) intent is pending confirmation + // before the service will permit the creation of the chat. This can happen in + // cases where the block chain is particularly slow (beyond our RPC timeouts). + PENDING = 3; + // MISSING_IDENTITY indicates that there is no identity for the user (creator). + MISSING_IDENTITY = 4; + // USER_NOT_FOUND indicates that (one of) the target user's was not found. + USER_NOT_FOUND = 5; + } + // The chat to use if the RPC was successful. + Metadata chat = 2; +} +message SendMessageRequest { + common.v1.ChatId chat_id = 1; + // Allowed content types that can be sent by client: + // - TextContent + // - ThankYouContent + repeated Content content = 2 ; + common.v1.SolanaAccountId owner = 3; + common.v1.Signature signature = 4; +} +message SendMessageResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + INVALID_CONTENT_TYPE = 2; + } + // The chat message that was sent if the RPC was succesful, which includes + // server-side metadata like the generated message ID and official timestamp + Message message = 2; +} +message AdvancePointerRequest { + common.v1.ChatId chat_id = 1; + Pointer pointer = 2; + common.v1.SolanaAccountId owner = 3; + common.v1.Signature signature = 4; +} +message AdvancePointerResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + MESSAGE_NOT_FOUND = 2; + } +} +message SetMuteStateRequest { + common.v1.ChatId chat_id = 1; + bool is_muted = 2; + common.v1.SolanaAccountId owner = 3; + common.v1.Signature signature = 4; +} +message SetMuteStateResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + CANT_MUTE = 2; + } +} +message NotifyIsTypingRequest { + common.v1.ChatId chat_id = 1; + bool is_typing = 2; + common.v1.SolanaAccountId owner = 3; + common.v1.Signature signature = 4; +} +message NotifyIsTypingResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + } +} +message MessageId { + // A lexicographically sortable ID that can be used to sort source of + // chat history. + bytes value = 1 ; +} +message MemberId { + // The publically available 'deposit' address of the user. + bytes value = 1 ; +} +enum ChatType { + UNKNOWN_CHAT_TYPE = 0; + TWO_WAY = 1; + // GROUP = 3; +} +enum Platform { + UNKNOWN_PLATFORM = 0; + TWITTER = 1; +} +enum PointerType { + UNKNOWN_POINTER_TYPE = 0; + SENT = 1; // Always inferred by OK result in SendMessageResponse or message presence in a chat + DELIVERED = 2; + READ = 3; +} +// A chat +// +// todo: Support is_verified in a clean way +message Metadata { + common.v1.ChatId chat_id = 1; + // The type of chat + ChatType type = 2 ; + // Cursor value for this chat for reference in subsequent GetChatsRequest + Cursor cursor = 3; + // The chat title, which is _only_ set by server if an explicit title + // was set. Otherwise, clients should fill in an appropriate chat title. + string title = 4 ; + // The members in this chat. + repeated Member members = 5 ; + // Whether or not the chat is muted (from the perspective of the caller). + bool is_muted = 6; + // Whether or not the chat is mutable (from the persective of the caller). + bool muteable = 7; + // Number of (estimated) unread message (from the perspective of the caller). + uint32 num_unread = 8; +} +// A message in a chat +message Message { + // Globally unique ID for this message + MessageId message_id = 1; + // The chat member that sent the message. For NOTIFICATION chats, this field + // is omitted since the chat has exactly 1 member. + MemberId sender_id = 2; + // Ordered message content. A message may have more than one piece of content. + repeated Content content = 3 ; + // Timestamp this message was generated at. This value is also encoded in + // any time-based UUID message IDs. + google.protobuf.Timestamp ts = 4; + // Cursor value for this message for reference in a paged GetMessagesRequest + Cursor cursor = 5; +} +// A user in a chat +message Member { + // Public AccountId (for is self...is derived via deposit address) + MemberId member_id = 1; + // The chat member's identity if it has been revealed. + // + // Multiple identities here? Well really only needs twitter/other handles + // Repeated PlatformHandles (where code doesn't matter)? + MemberIdentity identity = 2; + // Chat message state for this member. + // + // If set, the list may contain DELIVERED and READ pointers. SENT pointers + // are only shared between the sender and server, to indicate persistence. + // + // The server may wish to omit all pointers in various types of group chats + // or as relief valves. + repeated Pointer pointers = 3 ; + // If the member is the caller (where applicable), will be set to true. + bool is_self = 4; +} +// Identity to an external social platform that can be linked to a Code account +message MemberIdentity { + // The external social platform linked to this chat member + Platform platform = 1 ; + // The chat member's username on the external social platform. + string username = 2 ; + // TODO: If we need the profile pic, we need display name...both can maybe be removed? + // If present, the display name of the user. + string display_name = 3 ; + // If present, the URL of the users profile pic. + string profile_pic_url = 4 ; +} +// Pointer in a chat indicating a user's message history state in a chat. +message Pointer { + // The type of pointer indicates which user's message history state can be + // inferred from the pointer value. It is also possible to infer cross-pointer + // state. For example, if a chat member has a READ pointer for a message with + // ID N, then the DELIVERED pointer must be at least N. + PointerType type = 1 ; + // Everything at or before this message ID is considered to have the state + // inferred by the type of pointer. + MessageId value = 2; + // The chat member associated with this pointer state + MemberId member_id = 3; +} +// Content for a chat message +message Content { + oneof type { + TextContent text = 1; + LocalizedContent localized = 2; + ExchangeDataContent exchange_data = 3; + NaclBoxEncryptedContent nacl_box = 4; + } +} +// Raw text content +message TextContent { + string text = 1 ; +} +// Text content that is either a localization key that should be translated on +// client, or a server-side translated piece of text. +message LocalizedContent { + string key_or_text = 1 ; +} +// Exchange data content for movement of a value of Kin +message ExchangeDataContent { + enum Verb { + UNKNOWN = 0; + GAVE = 1; + RECEIVED = 2; + WITHDREW = 3; + DEPOSITED = 4; + SENT = 5; + RETURNED = 6; + SPENT = 7; + PAID = 8; + PURCHASED = 9; + RECEIVED_TIP = 10; + SENT_TIP = 11; + } + // Verb describing how the amount of Kin was exchanged + // + // Note: The current definition is not suitable outside a NOTIFICATION chat + // as not enough context is provided as to which member this verb is + // associated with. + Verb verb = 1 ; + // An amount of Kin being exchanged + oneof exchange_data { + transaction.v2.ExchangeData exact = 2; + transaction.v2.ExchangeDataWithoutRate partial = 3; + } + // An ID that can be referenced to the source of the exchange of Kin + oneof reference { + common.v1.IntentId intent = 4; + common.v1.Signature signature = 5; + } + // TODO: We need to be able to provide the parties involved, such that + // such that client can render it correctly. +} +// Encrypted piece of content using NaCl box encryption +message NaclBoxEncryptedContent { + // The sender's public key that is used to derive the shared private key for + // decryption for message content. + common.v1.SolanaAccountId peer_public_key = 1; + // Globally random nonce that is unique to this encrypted piece of content + bytes nonce = 2 ; + // The encrypted piece of message content + bytes encrypted_payload = 3 ; +} +// Opaque cursor used across paged APIs. Underlying bytes may change as paging +// strategies evolve. Expected length value will vary based on the RPC being +// executed. +message Cursor { + bytes value = 1 ; +} +message IsTyping { + MemberId member_id = 1; + // is_typing indicates whether or not the user is typing. + // If false, the user has explicitly stopped typing. + bool is_typing = 2; +} diff --git a/service/protos/src/main/proto/common/v1/model.proto b/definitions/code/protos/src/main/proto/common/v1/model.proto similarity index 100% rename from service/protos/src/main/proto/common/v1/model.proto rename to definitions/code/protos/src/main/proto/common/v1/model.proto diff --git a/service/protos/src/main/proto/contact/v1/contact_list_service.proto b/definitions/code/protos/src/main/proto/contact/v1/contact_list_service.proto similarity index 100% rename from service/protos/src/main/proto/contact/v1/contact_list_service.proto rename to definitions/code/protos/src/main/proto/contact/v1/contact_list_service.proto diff --git a/definitions/code/protos/src/main/proto/currency/v1/currency_service.proto b/definitions/code/protos/src/main/proto/currency/v1/currency_service.proto new file mode 100644 index 000000000..5458a224b --- /dev/null +++ b/definitions/code/protos/src/main/proto/currency/v1/currency_service.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; +package code.currency.v1; +option go_package = "github.com/code-payments/code-protobuf-api/generated/go/currency/v1;currency"; +option java_package = "com.codeinc.gen.currency.v1"; +option objc_class_prefix = "CPBCurrencyV1"; + +import "google/protobuf/timestamp.proto"; +service Currency { + // GetAllRates returns the exchange rates for Kin against all available currencies + rpc GetAllRates(GetAllRatesRequest) returns (GetAllRatesResponse); +} +message GetAllRatesRequest { + // If timestamp is included, the returned rate will be the most recent available + // exchange rate prior to the provided timestamp within the same day. Otherwise, + // the latest rates will be returned. + google.protobuf.Timestamp timestamp = 1; +} +message GetAllRatesResponse { + Result result = 1; + enum Result { + OK = 0; + // No currency data is available for the requested timestamp. + MISSING_DATA = 1; + } + // The time the exchange rates were observed + google.protobuf.Timestamp as_of = 2; + // The price of 1 Kin in different currencies, keyed on 3- or 4- letter lowercase currency code. + map rates = 3 ; +} diff --git a/service/protos/src/main/proto/device/v1/device_service.proto b/definitions/code/protos/src/main/proto/device/v1/device_service.proto similarity index 100% rename from service/protos/src/main/proto/device/v1/device_service.proto rename to definitions/code/protos/src/main/proto/device/v1/device_service.proto diff --git a/service/protos/src/main/proto/invite/v2/invite_service.proto b/definitions/code/protos/src/main/proto/invite/v2/invite_service.proto similarity index 100% rename from service/protos/src/main/proto/invite/v2/invite_service.proto rename to definitions/code/protos/src/main/proto/invite/v2/invite_service.proto diff --git a/service/protos/src/main/proto/messaging/v1/messaging_service.proto b/definitions/code/protos/src/main/proto/messaging/v1/messaging_service.proto similarity index 100% rename from service/protos/src/main/proto/messaging/v1/messaging_service.proto rename to definitions/code/protos/src/main/proto/messaging/v1/messaging_service.proto diff --git a/service/protos/src/main/proto/micropayment/v1/micro_payment_service.proto b/definitions/code/protos/src/main/proto/micropayment/v1/micro_payment_service.proto similarity index 100% rename from service/protos/src/main/proto/micropayment/v1/micro_payment_service.proto rename to definitions/code/protos/src/main/proto/micropayment/v1/micro_payment_service.proto diff --git a/service/protos/src/main/proto/phone/v1/phone_verification_service.proto b/definitions/code/protos/src/main/proto/phone/v1/phone_verification_service.proto similarity index 100% rename from service/protos/src/main/proto/phone/v1/phone_verification_service.proto rename to definitions/code/protos/src/main/proto/phone/v1/phone_verification_service.proto diff --git a/service/protos/src/main/proto/push/v1/push_service.proto b/definitions/code/protos/src/main/proto/push/v1/push_service.proto similarity index 100% rename from service/protos/src/main/proto/push/v1/push_service.proto rename to definitions/code/protos/src/main/proto/push/v1/push_service.proto diff --git a/service/protos/src/main/proto/transaction/v2/transaction_service.proto b/definitions/code/protos/src/main/proto/transaction/v2/transaction_service.proto similarity index 100% rename from service/protos/src/main/proto/transaction/v2/transaction_service.proto rename to definitions/code/protos/src/main/proto/transaction/v2/transaction_service.proto diff --git a/service/protos/src/main/proto/user/v1/identity_service.proto b/definitions/code/protos/src/main/proto/user/v1/identity_service.proto similarity index 100% rename from service/protos/src/main/proto/user/v1/identity_service.proto rename to definitions/code/protos/src/main/proto/user/v1/identity_service.proto diff --git a/definitions/flipchat/models/.gitignore b/definitions/flipchat/models/.gitignore new file mode 100644 index 000000000..9f2a07880 --- /dev/null +++ b/definitions/flipchat/models/.gitignore @@ -0,0 +1,2 @@ +build/ +.gradle/ diff --git a/definitions/flipchat/models/build.gradle.kts b/definitions/flipchat/models/build.gradle.kts new file mode 100644 index 000000000..b2f74da5b --- /dev/null +++ b/definitions/flipchat/models/build.gradle.kts @@ -0,0 +1,82 @@ +import org.apache.tools.ant.taskdefs.condition.Os + +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id("com.google.protobuf") +} + +val archSuffix = if (Os.isFamily(Os.FAMILY_MAC)) ":osx-x86_64" else "" + +version = "0.0.1" +group = "com.codeinc.fc.gen" + +dependencies { + protobuf(project(":definitions:flipchat:protos")) + + implementation(Libs.grpc_protobuf_lite) + implementation(Libs.grpc_stub) + + // Kotlin Generation + implementation(Libs.grpc_kotlin) + implementation(Libs.protobuf_kotlin_lite) + implementation(Libs.kotlinx_coroutines_core) +} + +kotlin { + jvmToolchain(17) +} + +android { + namespace = "${Android.codeNamespace}.defs.fc.models" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } +} + +tasks.withType().all { + kotlinOptions { + freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn") + } +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${Versions.protobuf}$archSuffix" + } + plugins { + create("java") { + artifact = "io.grpc:protoc-gen-grpc-java:${Versions.grpc}" + } + create("grpc") { + artifact = "io.grpc:protoc-gen-grpc-java:${Versions.grpc}" + } + create("grpckt") { + artifact = "io.grpc:protoc-gen-grpc-kotlin:1.4.1:jdk8@jar" + } + } + generateProtoTasks { + all().forEach { + it.plugins { + create("java") { + option("lite") + } + create("grpc") { + option("lite") + } + create("grpckt") { + option("lite") + } + } + it.builtins { + create("kotlin") { + option("lite") + } + } + } + } +} \ No newline at end of file diff --git a/definitions/flipchat/protos/.gitignore b/definitions/flipchat/protos/.gitignore new file mode 100644 index 000000000..9f2a07880 --- /dev/null +++ b/definitions/flipchat/protos/.gitignore @@ -0,0 +1,2 @@ +build/ +.gradle/ diff --git a/definitions/flipchat/protos/build.gradle.kts b/definitions/flipchat/protos/build.gradle.kts new file mode 100644 index 000000000..4cd4475f1 --- /dev/null +++ b/definitions/flipchat/protos/build.gradle.kts @@ -0,0 +1,11 @@ +// todo: maybe use variants / configurations to do both stub & stub-lite here + +// Note: We use the java-library plugin to get the protos into the artifact for this subproject +// because there doesn't seem to be an better way. +plugins { + `java-library` +} + +java { + sourceSets.getByName("main").resources.srcDir("src/main/proto") +} \ No newline at end of file diff --git a/definitions/flipchat/protos/src/main/proto/account/v1/account_service.proto b/definitions/flipchat/protos/src/main/proto/account/v1/account_service.proto new file mode 100644 index 000000000..96e76b04a --- /dev/null +++ b/definitions/flipchat/protos/src/main/proto/account/v1/account_service.proto @@ -0,0 +1,137 @@ +syntax = "proto3"; +package flipchat.account.v1; +option go_package = "github.com/code-payments/flipchat-protobuf-api/generated/go/account/v1;acountpb"; +option java_package = "com.codeinc.flipchat.gen.account.v1"; +option objc_class_prefix = "FCPBAccountV1"; +import "common/v1/common.proto"; +import "google/protobuf/timestamp.proto"; + +service Account { + // Register registers a new user, bound to the provided PublicKey. + // If the PublicKey is already in use, the previous user account is returned. + rpc Register(RegisterRequest) returns (RegisterResponse); + // Login retrieves the UserId (and in the future, potentially other information) + // required for 'recovering' an account. + rpc Login(LoginRequest) returns (LoginResponse); + // AuthorizePublicKey authorizes an additional PublicKey to an account. + rpc AuthorizePublicKey(AuthorizePublicKeyRequest) returns (AuthorizePublicKeyResponse); + // RevokePublicKey revokes a public key from an account. + // + // There must be at least one public key per account. For now, any authorized public key + // may revoke another public key, but this may change in the future. + rpc RevokePublicKey(RevokePublicKeyRequest) returns (RevokePublicKeyResponse); + // GetPaymentDestination gets the payment destination for a UserId + rpc GetPaymentDestination(GetPaymentDestinationRequest) returns (GetPaymentDestinationResponse); + // GetUserFlags gets user-specific flags + rpc GetUserFlags(GetUserFlagsRequest) returns (GetUserFlagsResponse); +} +message RegisterRequest { + // PublicKey the public key that is authorized to perform actions on the + // registered users behalf. + common.v1.PublicKey public_key = 1; + // Signature of this message (without the signature), using the provided keypaid. + common.v1.Signature signature = 2; + // Deprecated: New account creation flow requires using profile service after IAP + string display_name = 3 ; +} +message RegisterResponse { + Result result = 1; + enum Result { + OK = 0; + INVALID_SIGNATURE = 1; + INVALID_DISPLAY_NAME = 2; + DENIED = 3; + } + // Error reason contains the reason for the error, if the + // result > 1. This allows for server to impose moderation restrictions + // on user provided fields. + string error_reason = 2; + // The UserId associated with the account. + common.v1.UserId user_id = 3; +} +message LoginRequest { + // Timestamp is the timestamp the request was generated. + // + // The server may reject the request if the timestamp is too far off + // the current (server) time. This is to prevent replay attacks. + google.protobuf.Timestamp timestamp = 1; + common.v1.Auth auth = 2; +} +message LoginResponse { + Result result = 1; + enum Result { + OK = 0; + INVALID_TIMESTAMP = 1; + DENIED = 2; + } + // UserId is the user associated with the PubKey/Auth. + common.v1.UserId user_id = 2; +} +message AuthorizePublicKeyRequest { + // UserId to bound the new public key to. + common.v1.UserId user_id = 1; + // PublicKey of the account to be added. + common.v1.PublicKey public_key = 2; + // Signature of this message, not including auth or signature, using the + // new public key. + common.v1.Signature signature = 3; + common.v1.Auth auth = 4; +} +message AuthorizePublicKeyResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + } +} +message RevokePublicKeyRequest { + // UserId to remove the public key from. + common.v1.UserId user_id = 1; + // PublicKey to remove. + common.v1.PublicKey public_key = 2; + common.v1.Auth auth = 4; +} +message RevokePublicKeyResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + LAST_PUB_KEY = 2; + } +} +message GetPaymentDestinationRequest { + // UserId to get the payment destination from. + common.v1.UserId user_id = 1; +} +message GetPaymentDestinationResponse { + Result result = 1; + enum Result { + OK = 0; + NOT_FOUND = 1; + } + // Payment destination for the UserId. + common.v1.PublicKey payment_destination = 2; +} +message GetUserFlagsRequest { + // UserId to get user flags for. + common.v1.UserId user_id = 1; + common.v1.Auth auth = 2; +} +message GetUserFlagsResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + } + UserFlags user_flags = 2; +} +message UserFlags { + // Is this user associated with a Flipchat staff member? + bool is_staff = 1; + // The fee payment amount for starting a new group + common.v1.PaymentAmount start_group_fee = 2; + // The destination account where fees should be paid to + common.v1.PublicKey fee_destination = 3; + // Is this a fully registered account using IAP for account creation? + bool is_registered_account = 4; +} diff --git a/definitions/flipchat/protos/src/main/proto/badge/v1/badge_service.proto b/definitions/flipchat/protos/src/main/proto/badge/v1/badge_service.proto new file mode 100644 index 000000000..3c4d45c8e --- /dev/null +++ b/definitions/flipchat/protos/src/main/proto/badge/v1/badge_service.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; +package flipchat.badge.v1; +option go_package = "github.com/code-payments/flipchat-protobuf-api/generated/go/badge/v1;badgepb"; +option java_package = "com.codeinc.flipchat.gen.badge.v1"; +option objc_class_prefix = "FPBBadgeV1"; +import "common/v1/common.proto"; + +service Badge { + // ResetBadgeCount resets an owner account's app icon badge count back to zero + rpc ResetBadgeCount(ResetBadgeCountRequest) returns (ResetBadgeCountResponse); +} +message ResetBadgeCountRequest { + common.v1.UserId user_id = 1; + common.v1.Auth auth = 2; +} +message ResetBadgeCountResponse { + Result result = 1; + enum Result { + OK = 0; + } +} diff --git a/definitions/flipchat/protos/src/main/proto/chat/v1/chat_service.proto b/definitions/flipchat/protos/src/main/proto/chat/v1/chat_service.proto new file mode 100644 index 000000000..1a45d6633 --- /dev/null +++ b/definitions/flipchat/protos/src/main/proto/chat/v1/chat_service.proto @@ -0,0 +1,588 @@ +syntax = "proto3"; +package flipchat.chat.v1; +option go_package = "github.com/code-payments/flipchat-protobuf-api/generated/go/chat/v1;chatpb"; +option java_package = "com.codeinc.flipchat.gen.chat.v1"; +option objc_class_prefix = "FCPBChatV1"; +import "common/v1/common.proto"; +import "messaging/v1/model.proto"; +import "google/protobuf/timestamp.proto"; + +service Chat { + // StreamChatEvents streams all chat events for the requesting user. + // + // Chat events will include any update to a chat, including: + // 1. Metadata changes. + // 2. Membership changes. + // 3. Latest messages. + // + // The server will optionally filter out some events depending on load + // and chat type. For example, Broadcast chats may not receive latest + // messages. + // + // Clients should use GetMessages to backfill in any historical messages + // for a chat. It should be sufficient to rely on ChatEvents for some types + // of chats, but using StreamMessages provides a guarentee of message events + // for all chats. + rpc StreamChatEvents(stream StreamChatEventsRequest) returns (stream StreamChatEventsResponse); + // GetChats gets the set of chats for an owner account using a paged API. + // This RPC is aware of all identities tied to the owner account. + rpc GetChats(GetChatsRequest) returns (GetChatsResponse); + // GetChat returns the metadata for a specific chat. + rpc GetChat(GetChatRequest) returns (GetChatResponse); + // StartChat starts a chat. The RPC call is idempotent and will use existing + // chats whenever applicable within the context of message routing. + rpc StartChat(StartChatRequest) returns (StartChatResponse); + // JoinChat joins a given chat. + rpc JoinChat(JoinChatRequest) returns (JoinChatResponse); + // LeaveChat leaves a given chat. + rpc LeaveChat(LeaveChatRequest) returns (LeaveChatResponse); + // OpenChat opens a chat up for messaging across all members + rpc OpenChat(OpenChatRequest) returns (OpenChatResponse); + // CloseChat closes a chat up for messaging to just the chat owner + rpc CloseChat(CloseChatRequest) returns (CloseChatResponse); + // SetDisplayName sets a chat's display name. If the display name isn't allowed, + // then a set of alternate suggestions may be provided + rpc SetDisplayName(SetDisplayNameRequest) returns (SetDisplayNameResponse); + // SetCoverCharge sets a chat's cover charge + // + // Deprecated: Use SetMessagingFee instead + rpc SetCoverCharge(SetCoverChargeRequest) returns (SetCoverChargeResponse); + // SetMessagingFee sets a chat's messaging fee + rpc SetMessagingFee(SetMessagingFeeRequest) returns (SetMessagingFeeResponse); + // GetMemberUpdates gets member updates for a given chat + rpc GetMemberUpdates(GetMemberUpdatesRequest) returns (GetMemberUpdatesResponse); + // PromoteUser promotes a user to an elevated permission state + rpc PromoteUser(PromoteUserRequest) returns (PromoteUserResponse); + // DemoteUser demotes a user to a lower permission state + rpc DemoteUser(DemoteUserRequest) returns (DemoteUserResponse); + // RemoveUser removes a user from a chat + rpc RemoveUser(RemoveUserRequest) returns (RemoveUserResponse); + // MuteUser mutes a user in the chat and removes their ability to send messages + rpc MuteUser(MuteUserRequest) returns (MuteUserResponse); + // MuteChat mutes a chat and disables push notifications + rpc MuteChat(MuteChatRequest) returns (MuteChatResponse); + // UnmuteChat unmutes a chat and enables push notifications + rpc UnmuteChat(UnmuteChatRequest) returns (UnmuteChatResponse); + // ReportUser reports a user for a given message + // + // todo: might belong in a different service long-term + rpc ReportUser(ReportUserRequest) returns (ReportUserResponse); +} +message StreamChatEventsRequest { + oneof type { + Params params = 1; + common.v1.ClientPong pong = 2; + } + message Params { + common.v1.Auth auth = 1; + // ts contains the time for stream open. + // + // It is used primarily as a nonce for auth. Server may reject + // timestamps that are too far in the future or past. + google.protobuf.Timestamp ts = 2; + } +} +message StreamChatEventsResponse { + oneof type { + common.v1.ServerPing ping = 1; + StreamError error = 2; + EventBatch events = 3; + } + message StreamError { + Code code = 1; + enum Code { + DENIED = 0; + } + } + message EventBatch { + repeated ChatUpdate updates = 1 ; + } + // ChatUpdate contains a set of updates for a given chat id. + // + // Only the relevant fields will be set on update. On initial + // stream open, all fields will be set, however. + message ChatUpdate { + common.v1.ChatId chat_id = 1; + // Metadata contains the latest (full) chat metadata. + // + // Deprecated: Use metadata_updates instead. For backwards compatibility + // this will only contain full metadata refreshes. + Metadata metadata = 2; + // MetadataUpdate contains updates to a chat metadata + repeated MetadataUpdate metadata_updates = 7; + // MemberUpdate contains an update to the membership set. + // + // Deprecated: Use member_updates instead. For backwards compatibility + // this will only contain full member refreshes. + MemberUpdate member_update = 3; + // MemberUpdate contains updates to the membership set. + repeated MemberUpdate member_updates = 8; + // Message contains the last known message of the chat. + messaging.v1.Message last_message = 4; + // Relevant update to a chat member's pointer state, where 'relevant' means + // "relevant to UI updates". For example, when a user has read the latest + // message. + PointerUpdate pointer = 5; + message PointerUpdate { + common.v1.UserId member = 1; + messaging.v1.Pointer pointer = 2; + } + // IsTyping indicates whether or not someone is typing in the group. + messaging.v1.IsTyping is_typing = 6; + } +} +message GetChatsRequest { + common.v1.QueryOptions query_options = 1; + common.v1.Auth auth = 2; +} +message GetChatsResponse { + Result result = 1; + enum Result { + OK = 0; + } + repeated Metadata chats = 2 ; +} +message GetChatRequest { + oneof identifier { + common.v1.ChatId chat_id = 1; + uint64 room_number = 2; + } + bool exclude_members = 9; + // Auth is an optional field that authenticates the call, which + // can be used to fill out extra information in the Metadata. + common.v1.Auth auth = 10; +} +message GetChatResponse { + Result result = 1; + enum Result { + OK = 0; + NOT_FOUND = 1; + } + // Metadata is the chat metadata, if result == OK. + // + // The contents of the metadata may change whether or not the + // call was authenticated. + Metadata metadata = 2; + // Members contains the chat members, if result == OK and were requested. + repeated Member members = 3; +} +message StartChatRequest { + oneof parameters { + StartTwoWayChatParameters two_way_chat = 1; + StartGroupChatParameters group_chat = 2; + } + // StartTwoWayChatParameters contains the parameters required to start + // or recover a two way chat between the caller and the specified 'other_user'. + // + // The 'other_user' is currently the 'tip_address', normally retrieved from + // user.Identity.GetTwitterUser(username). + message StartTwoWayChatParameters { + // The account id of the user the caller wishes to chat with. + common.v1.UserId other_user_id = 1; + } + message StartGroupChatParameters { + // A set of users (not including self) to initially set in the group chat. + repeated common.v1.UserId users = 1 ; + // Reserved for display_name field + reserved 2; + // Optional payment for creating the group. It's up to server to decide + // if the user is allowed to create a group without payment. + common.v1.IntentId payment_intent = 3; + } + common.v1.Auth auth = 10; +} +message StartChatResponse { + Result result = 1; + enum Result { + OK = 0; + // DENIED indicates the caller is not allowed to start/join the chat. + DENIED = 1; + // USER_NOT_FOUND indicates that (one of) the target user's was not found. + USER_NOT_FOUND = 2; + } + // The chat to use, if result == OK. + Metadata chat = 2; + // Members contains the chat members, if result == OK. + repeated Member members = 3; +} +message StartGroupChatPaymentMetadata { + // The user creating the group chat, who will be the initial owner + common.v1.UserId user_id = 1; +} +message JoinChatRequest { + oneof identifier { + common.v1.ChatId chat_id = 1; + uint64 room_id = 2; + } + // Does the user want to join without the ability to send messages in the chat? + // If so, then payment_intent is not required? Otherwise, it is. + bool without_send_permission = 8; + // The payment for joining a chat, which is required for sending messages in + // the chat. + // + // Note: The chat owner can always bypass payment. + common.v1.IntentId payment_intent = 9; + common.v1.Auth auth = 10; +} +message JoinChatResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + } + // The chat metadata, if join was successful. + Metadata metadata = 2; + // The members of the chat, if join was successful. + repeated Member members = 3; +} +message JoinChatPaymentMetadata { + // The user joining the chat + common.v1.UserId user_id = 1; + // The chat that the user is joining + common.v1.ChatId chat_id = 2; +} +message LeaveChatRequest { + common.v1.ChatId chat_id = 1; + common.v1.Auth auth = 2; +} +message LeaveChatResponse { + Result result = 1; + enum Result { + OK = 0; + } +} +message OpenChatRequest { + // The chat that is being opened + common.v1.ChatId chat_id = 1; + common.v1.Auth auth = 2; +} +message OpenChatResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + } +} +message CloseChatRequest { + /// The chat that is being closed + common.v1.ChatId chat_id = 1; + common.v1.Auth auth = 2; +} +message CloseChatResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + } +} +message SetDisplayNameRequest { + common.v1.ChatId chat_id = 1; + string display_name = 2 ; + common.v1.Auth auth = 3; +} +message SetDisplayNameResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + CANT_SET = 2; + } + repeated string alternate_suggestions = 2 ; +} +message SetCoverChargeRequest { + common.v1.ChatId chat_id = 1; + common.v1.PaymentAmount cover_charge = 2; + common.v1.Auth auth = 3; +} +message SetCoverChargeResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + CANT_SET = 2; + } +} +message SetMessagingFeeRequest { + common.v1.ChatId chat_id = 1; + common.v1.PaymentAmount messaging_fee = 2; + common.v1.Auth auth = 3; +} +message SetMessagingFeeResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + CANT_SET = 2; + } +} +message GetMemberUpdatesRequest { + common.v1.ChatId chat_id = 1; + // If not provided, a full refresh is performed. Server may also choose + // to compact updates into a full or individual refresh. + common.v1.PagingToken paging_token = 2; + // Auth is an optional field that authenticates the call, which + // can be used to fill out extra information. + common.v1.Auth auth = 3; +} +message GetMemberUpdatesResponse { + Result result = 1; + enum Result { + OK = 0; + NOT_FOUND = 1; + } + repeated MemberUpdate updates = 2 ; +} +message PromoteUserRequest { + common.v1.ChatId chat_id = 1; + common.v1.UserId user_id = 2; + // Enables send permissions when value is true + bool enable_send_permission = 3; + common.v1.Auth auth = 100; +} +message PromoteUserResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + } +} +message DemoteUserRequest { + common.v1.ChatId chat_id = 1; + common.v1.UserId user_id = 2; + // Disables send permissions when value is true + bool disable_send_permission = 3; + common.v1.Auth auth = 100; +} +message DemoteUserResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + } +} +message RemoveUserRequest{ + common.v1.ChatId chat_id = 1; + common.v1.UserId user_id = 2; + common.v1.Auth auth = 3; +} +message RemoveUserResponse{ + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + } +} +message MuteUserRequest{ + common.v1.ChatId chat_id = 1; + common.v1.UserId user_id = 2; + common.v1.Auth auth = 3; +} +message MuteUserResponse{ + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + } +} +message MuteChatRequest { + common.v1.ChatId chat_id = 1; + common.v1.Auth auth = 2; +} +message MuteChatResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + } +} +message UnmuteChatRequest { + common.v1.ChatId chat_id = 1; + common.v1.Auth auth = 2; +} +message UnmuteChatResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + } +} +message ReportUserRequest{ + common.v1.UserId user_id = 1; + messaging.v1.MessageId message_id = 2; + common.v1.Auth auth = 3; +} +message ReportUserResponse{ + Result result = 1; + enum Result { + OK = 0; + } +} +message Metadata { + common.v1.ChatId chat_id = 1; + // The type of chat + ChatType type = 2 ; + enum ChatType { + UNKNOWN = 0; + TWO_WAY = 1; + GROUP = 2; + } + // The chat display name + string display_name = 3 ; + // If non-zero, the room number associated with the chat. + uint64 room_number = 4; + // Are push notifications enabled for this chat (from the perspective of the caller)? + bool is_push_enabled = 5; + // Can the user disable push notifications for this chat using MuteChat? + bool can_disable_push = 6; + // Number of (estimated) unread message (from the perspective of the caller). + uint32 num_unread = 7; + // If there are more unread messages than indicated by num_unread. If this is + // true, client should show num_unread+ as the unread count. + bool has_more_unread = 11; + // Owner is the owner/creator of the chat. + // + // This is a super priviledge role, in which there can only be one. + // This role is displayed as a 'host' currently. + common.v1.UserId owner = 8; + // If present, the fee that must be paid to send a message as a non-regular + // chat member. + // + // This replaces the legacy cover charge mechanic, which is deprecated + common.v1.PaymentAmount messaging_fee = 9; + // The timestamp of the last activity in this chat + google.protobuf.Timestamp last_activity = 10; + // The status as to whether the room is open or closed. This may be + // omitted for chats where it doesn't apply. If not provided, it's + // safe to assume the chat is open indefinitely until otherwise provided. + OpenStatus open_status = 12; +} +// todo: In the future, we may add additional fields like open/closed until a timestamp, etc. +// For backwards compatibility, client can always refer to is_currently_open for whether +// a room is open right now or not for the purposes of sending messages. +// todo: A better name for this +message OpenStatus { + bool is_currently_open = 1; +} +message MetadataUpdate { + oneof kind { + FullRefresh full_refresh = 1; + UnreadCountChanged unread_count_changed = 2; + DisplayNameChanged display_name_changed = 3; + MessagingFeeChanged messaging_fee_changed = 4; + LastActivityChanged last_activity_changed = 5; + OpenStatusChanged open_status_changed = 6; + } + // Refreshes the entire chat metadata + message FullRefresh { + Metadata metadata = 1; + } + // New message in the chat has generated a new unread count + message UnreadCountChanged { + // Number of (estimated) unread message + uint32 num_unread = 1; + // If there are more unread messages than indicated by num_unread. + // If this is true, client should show num_unread+ as the unread count. + bool has_more_unread = 2; + } + // The chat display name has been updated to a new value + message DisplayNameChanged { + string new_display_name = 1 ; + } + // The chat messaging fee has been updated to a new value + message MessagingFeeChanged { + common.v1.PaymentAmount new_messaging_fee = 1; + } + // The last activity timestamp has changed to a newer value + message LastActivityChanged { + google.protobuf.Timestamp new_last_activity = 1; + } + // The open status has changed to a newer value + message OpenStatusChanged { + OpenStatus new_open_status = 1; + } +} +message Member { + common.v1.UserId user_id = 1; + // The chat member's identity/profile information. + // + // It is a light weight version of the users full profile, which + // can be retrieved from the Profile service. + MemberIdentity identity = 2; + // Chat message state for this member. + // + // If set, the list may contain DELIVERED and READ pointers. SENT pointers + // are only shared between the sender and server, to indicate persistence. + // + // The server may wish to omit all pointers in various types of group chats + // or as relief valves. + repeated messaging.v1.Pointer pointers = 3 ; + // If the member is the caller (where applicable), will be set to true. + bool is_self = 4; + // Does the chat member have permission to perform moderation actions in + // the chat? + bool has_moderator_permission = 5; + // Has the chat member been muted by a moderator? If so, they cannot send + // messages, even if they paid for the permission. + bool is_muted = 6; + // Does the chat member have permission to send messages in the chat? If + // not, the user is considered to be a spectator or listener. Otherwise, + // they are a speaker. + bool has_send_permission = 7; +} +message MemberIdentity { + // If present, the display name of the user. + string display_name = 1 ; + // If present, the URL of the users profile pic. + string profile_pic_url = 2 ; +} +message MemberUpdate { + oneof kind { + FullRefresh full_refresh = 1; + IndividualRefresh individual_refresh = 2; + Joined joined = 3; + Left left = 4; + Removed removed = 5; + Muted muted = 6; + Promoted promoted = 7; + Demoted demoted = 8; + } + common.v1.PagingToken paging_token = 1000; + // Refreshes the state of the entire chat membership + message FullRefresh { + repeated Member members = 1 ; + } + // Refreshes the state of an individual member in the chat + message IndividualRefresh { + Member member = 1; + } + // Member joined the chat via the JoinChat RPC + message Joined { + Member member = 1; + } + // Member left the chat via the LeaveChat RPC + message Left { + common.v1.UserId member = 1; + } + // Member was removed from the chat via the RemoveUser RPC + message Removed { + common.v1.UserId member = 1; + common.v1.UserId removed_by = 2; + } + // Member was muted in the chat via the MuteUser RPC + message Muted { + common.v1.UserId member = 1; + common.v1.UserId muted_by = 2; + } + // Member was promoted in the chat via the PromoteUser RPC + message Promoted { + common.v1.UserId member = 1; + common.v1.UserId promoted_by = 2; + bool send_permission_enabled = 3; + } + // Member was demoted in the chat via the DemoteUser RPC + message Demoted { + common.v1.UserId member = 1; + common.v1.UserId demoted_by = 2; + bool send_permission_disabled = 3; + } +} diff --git a/definitions/flipchat/protos/src/main/proto/common/v1/common.proto b/definitions/flipchat/protos/src/main/proto/common/v1/common.proto new file mode 100644 index 000000000..618f3bddf --- /dev/null +++ b/definitions/flipchat/protos/src/main/proto/common/v1/common.proto @@ -0,0 +1,95 @@ +syntax = "proto3"; +package flipchat.common.v1; +option go_package = "github.com/code-payments/flipchat-protobuf-api/generated/go/common/v1;commonpb"; +option java_package = "com.codeinc.flipchat.gen.common.v1"; +option objc_class_prefix = "FPBCommonV1"; +import "google/protobuf/duration.proto"; +import "google/protobuf/timestamp.proto"; + +// Auth provides an authentication information for RPCs/messages. +// +// Currently, only a single form is supported, but it may be useful in +// the future to rely on session tokens instead. +message Auth { + oneof kind { + // KeyPair uses pub key cryptography to verify. + KeyPair key_pair = 1; + } + // KeyPair uses a keypair to verify a message. + // + // The signature should be of the encapsulating proto message, + // _without_ the Auth section being set. + message KeyPair { + PublicKey pub_key = 1; + Signature signature = 2; + } +} +message UserId { + bytes value = 1 ; +} +message ChatId { + // Sufficient space is left for a consistent hash value, though other types + // of values may be used. + bytes value = 1 ; +} +// AppInstallId is a unque ID tied to a client app installation. It does not +// identify a device. Value should remain private and not be shared across +// installs. +message AppInstallId { + string value = 1 ; +} +// Locale is a user locale consisting of a combination of language, script and region +message Locale { + string value = 1; +} +message PublicKey { + bytes value = 1 ; +} +message IntentId { + bytes value = 1 ; +} +message Signature { + bytes value = 1 ; +} +message PaymentAmount { + uint64 quarks = 1; +} +message ServerPing { + // Timestamp the ping was sent on the stream, for client to get a sense + // of potential network latency + google.protobuf.Timestamp timestamp = 1; + // The delay server will apply before sending the next ping + google.protobuf.Duration ping_delay = 2; +} +message ClientPong { + // Timestamp the Pong was sent on the stream, for server to get a sense + // of potential network latency + google.protobuf.Timestamp timestamp = 1; +} +message PagingToken { + // Value contains a value of an identifier of the collection in common. + // + // For example, GetChats uses the ChatId.Value, where GetMessages uses MessageId.Value + // as the contents. It does _not_ contain the serialized ChatId or MessageId. + bytes value = 1 ; +} +message QueryOptions { + // PageSize limits the maximum page size of a response. + // + // Server may choose to return less items. If empty, server + // may select an arbitrary page size. + int64 page_size = 1; + // PagingToken is token that can be extracted from the identifier of a collection. + PagingToken paging_token = 2; + // Order is the order of elements, if applicable. + Order order = 3; + enum Order { + ASC = 0; + DESC = 1; + } +} +enum Platform { + UNKNOWN = 0; + APPLE = 1; + GOOGLE = 2; +} diff --git a/definitions/flipchat/protos/src/main/proto/iap/v1/iap_service.proto b/definitions/flipchat/protos/src/main/proto/iap/v1/iap_service.proto new file mode 100644 index 000000000..f8fd43423 --- /dev/null +++ b/definitions/flipchat/protos/src/main/proto/iap/v1/iap_service.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; +package flipchat.iap.v1; +option go_package = "github.com/code-payments/flipchat-protobuf-api/generated/go/iap/v1;iappb"; +option java_package = "com.codeinc.flipchat.gen.iap.v1"; +option objc_class_prefix = "FPBIapV1"; +import "common/v1/common.proto"; + +service Iap { + // OnPurchaseCompleted is called when an IAP has been completed + rpc OnPurchaseCompleted(OnPurchaseCompletedRequest) returns (OnPurchaseCompletedResponse); +} +message OnPurchaseCompletedRequest { + common.v1.Platform platform = 1; + Receipt receipt = 2; + common.v1.Auth auth = 3; +} +message OnPurchaseCompletedResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + INVALID_RECEIPT = 2; // Returned if the receipt is invalid, or not in a completed payment state + } +} +message Receipt { + string value = 1 ; +} diff --git a/definitions/flipchat/protos/src/main/proto/messaging/v1/messaging_service.proto b/definitions/flipchat/protos/src/main/proto/messaging/v1/messaging_service.proto new file mode 100644 index 000000000..f2bcf4967 --- /dev/null +++ b/definitions/flipchat/protos/src/main/proto/messaging/v1/messaging_service.proto @@ -0,0 +1,146 @@ +syntax = "proto3"; +package flipchat.messaging.v1; +option go_package = "github.com/code-payments/flipchat-protobuf-api/generated/go/messaging/v1;messagingpb"; +option java_package = "com.codeinc.flipchat.gen.messaging.v1"; +option objc_class_prefix = "FCPBMessagingV1"; +import "common/v1/common.proto"; +import "messaging/v1/model.proto"; + +service Messaging { + // StreamMessages streams all messages/message states for the requested chat. + rpc StreamMessages(stream StreamMessagesRequest) returns (stream StreamMessagesResponse); + // GetMessage gets a single message in a chat + rpc GetMessage(GetMessageRequest) returns (GetMessageResponse); + // GetMessages gets the set of messages for a chat using a paged and batched APIs + rpc GetMessages(GetMessagesRequest) returns (GetMessagesResponse); + // SendMessage sends a message to a chat. + rpc SendMessage(SendMessageRequest) returns (SendMessageResponse); + // AdvancePointer advances a pointer in message history for a chat member. + rpc AdvancePointer(AdvancePointerRequest) returns (AdvancePointerResponse); + // NotifyIsTypingRequest notifies a chat that the sending member is typing. + // + // These requests are transient, and may be dropped at any point. + rpc NotifyIsTyping(NotifyIsTypingRequest) returns (NotifyIsTypingResponse); +} +message StreamMessagesRequest { + oneof type { + Params params = 1; + common.v1.ClientPong pong = 2; + } + message Params { + common.v1.ChatId chat_id = 1; + // Deprecated: stream flushes are no longer supported + oneof resume { + MessageId last_known_message_id = 2; + bool latest_only = 3; + } + common.v1.Auth auth = 4; + } +} +message StreamMessagesResponse { + oneof type { + common.v1.ServerPing ping = 1; + StreamError error = 2; + MessageBatch messages = 3; + } + message StreamError { + Code code = 1; + enum Code { + DENIED = 0; + } + } +} +message GetMessageRequest { + common.v1.ChatId chat_id = 1; + MessageId message_id = 2; + common.v1.Auth auth = 3; +} +message GetMessageResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + NOT_FOUND = 2; + } + Message message = 2; +} +message GetMessagesRequest { + common.v1.ChatId chat_id = 1; + // If not set, defaults to an ascending query option without a page token and server-defined limit + oneof query { + common.v1.QueryOptions options = 2; + MessageIdBatch message_ids = 3; + } + common.v1.Auth auth = 5; +} +message GetMessagesResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + } + repeated Message messages = 2 ; +} +message SendMessageRequest { + common.v1.ChatId chat_id = 1; + // Allowed content types that can be sent by client: + // - TextContent + // - ReactionContent + // - ReplyContent + // - TipContent + // - DeleteMessageContent + // - ReviewContent + repeated Content content = 2 ; + common.v1.Auth auth = 3; + // Intent ID for message contents that require a payment + common.v1.IntentId payment_intent = 4; +} +message SendMessageResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + } + // The chat message that was sent if the RPC was succesful, which includes + // server-side metadata like the generated message ID and official timestamp + Message message = 2; +} +message SendMessageAsListenerPaymentMetadata { + // The chat where the message is being sent + common.v1.ChatId chat_id = 1; + // The user sending the message + common.v1.UserId user_id = 2; +} +message SendTipMessagePaymentMetadata { + // The chat where the message is being tipped + common.v1.ChatId chat_id = 1; + // The message that is being tipped + MessageId message_id = 2; + // The user sending the tip + common.v1.UserId tipper_id = 3; +} +message AdvancePointerRequest { + common.v1.ChatId chat_id = 1; + Pointer pointer = 2; + common.v1.Auth auth = 3; +} +message AdvancePointerResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + MESSAGE_NOT_FOUND = 2; + } +} +message NotifyIsTypingRequest { + common.v1.ChatId chat_id = 1; + bool is_typing = 2; + common.v1.Auth auth = 3; +} +message NotifyIsTypingResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + } +} diff --git a/definitions/flipchat/protos/src/main/proto/messaging/v1/model.proto b/definitions/flipchat/protos/src/main/proto/messaging/v1/model.proto new file mode 100644 index 000000000..be372f342 --- /dev/null +++ b/definitions/flipchat/protos/src/main/proto/messaging/v1/model.proto @@ -0,0 +1,135 @@ +syntax = "proto3"; +package flipchat.messaging.v1; +option go_package = "github.com/code-payments/flipchat-protobuf-api/generated/go/messaging/v1;messagingpb"; +option java_package = "com.codeinc.flipchat.gen.messaging.v1"; +option objc_class_prefix = "FCPBMessagingV1"; +import "common/v1/common.proto"; +import "google/protobuf/timestamp.proto"; + +message MessageId { + // A lexicographically sortable ID that can be used to sort source of + // chat history. + bytes value = 1 ; +} +message MessageIdBatch { + repeated MessageId message_ids = 1 ; +} +// A message in a chat +message Message { + // Globally unique ID for this message + MessageId message_id = 1; + // The chat member that sent the message. For NOTIFICATION chats, this field + // is omitted since the chat has exactly 1 member. + common.v1.UserId sender_id = 2; + // Message content, which is currently guaranteed to have exactly one item. + repeated Content content = 3 ; + // Timestamp this message was generated at. This value is also encoded in + // any time-based UUID message IDs. + google.protobuf.Timestamp ts = 4; + // If sender_id is provided, were they off stage at the time of sending + // this message + bool was_sender_off_stage = 5; +} +message MessageBatch { + repeated Message messages = 1 ; +} +// Pointer in a chat indicating a user's message history state in a chat. +message Pointer { + // The type of pointer indicates which user's message history state can be + // inferred from the pointer value. It is also possible to infer cross-pointer + // state. For example, if a chat member has a READ pointer for a message with + // ID N, then the DELIVERED pointer must be at least N. + Type type = 1 ; + enum Type { + UNKNOWN = 0; + SENT = 1; // Always inferred by OK result in SendMessageResponse or message presence in a chat + DELIVERED = 2; + READ = 3; + } + // Everything at or before this message ID is considered to have the state + // inferred by the type of pointer. + MessageId value = 2; +} +message IsTyping { + common.v1.UserId user_id = 1; + // is_typing indicates whether or not the user is typing. + // If false, the user has explicitly stopped typing. + bool is_typing = 2; +} +// Content for a chat message +message Content { + oneof type { + TextContent text = 1; + LocalizedAnnouncementContent localized_announcement = 2; + ReactionContent reaction = 5; + ReplyContent reply = 6; + TipContent tip = 7; + DeleteMessageContent deleted = 8; + ReviewContent review = 9; + ActionableAnnouncementContent actionable_announcement = 10; + } + reserved 3; // ExchangeDataContent + reserved 4; // NaclBoxEncryptedContent +} +// Raw text content +message TextContent { + string text = 1 ; +} +// LocalizedAnnouncementContent content is an annoucement that is either a +// localization key that should be translated on client, or a server-side +// translated piece of text. +message LocalizedAnnouncementContent { + string key_or_text = 1 ; + // todo: define arguments list + reserved 2; +} +// ActionableAnnouncementContent is like LocalizedAnnouncementContent, but +// contains additional metadata for actions +message ActionableAnnouncementContent { + string key_or_text = 1 ; + // todo: define arguments list + reserved 2; + // An action that can be taken by a user + Action action = 3; + message Action { + oneof type { + + ShareRoomLink share_room_link = 1; + } + // Displays a button to share a link to a room + message ShareRoomLink { + } + } +} +// Emoji reaction to another message +message ReactionContent { + // The message ID of the message this reaction is associated with + MessageId original_message_id = 1 ; + // The emoji or reaction symbol + string emoji = 2 ; +} +// Text reply of another message +message ReplyContent { + // The message ID of the message this reply is referencing + MessageId original_message_id = 1 ; + // The reply text, which can be handled similarly to TextContent + string reply_text = 2 ; +} +message TipContent { + // The message ID of the message this tip is referencing + MessageId original_message_id = 1 ; + // The amount tipped for the message + common.v1.PaymentAmount tip_amount = 2; +} +message DeleteMessageContent { + // The message ID of the message that was deleted + MessageId original_message_id = 1 ; +} +message ReviewContent { + // The message ID of the message that is being reviewed. Currently, only + // off stage messages can be reviewed + MessageId original_message_id = 1 ; + // Whether the message has been approved. In the event of multiple reviews, + // the first message in the message log takes priority. + bool is_approved = 2; +} diff --git a/definitions/flipchat/protos/src/main/proto/profile/v1/model.proto b/definitions/flipchat/protos/src/main/proto/profile/v1/model.proto new file mode 100644 index 000000000..2cbccf7df --- /dev/null +++ b/definitions/flipchat/protos/src/main/proto/profile/v1/model.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; +package flipchat.profile.v1; +option go_package = "github.com/code-payments/flipchat-protobuf-api/generated/go/profile/v1;profilepb"; +option java_package = "com.codeinc.flipchat.gen.profile.v1"; +option objc_class_prefix = "FCPBProfileV1"; + +message UserProfile { + // DisplayName is the display name of the user (if found). + string display_name = 1 ; +} diff --git a/definitions/flipchat/protos/src/main/proto/profile/v1/profile_service.proto b/definitions/flipchat/protos/src/main/proto/profile/v1/profile_service.proto new file mode 100644 index 000000000..9ceac1379 --- /dev/null +++ b/definitions/flipchat/protos/src/main/proto/profile/v1/profile_service.proto @@ -0,0 +1,40 @@ +syntax = "proto3"; +package flipchat.profile.v1; +option go_package = "github.com/code-payments/flipchat-protobuf-api/generated/go/profile/v1;profilepb"; +option java_package = "com.codeinc.flipchat.gen.profile.v1"; +option objc_class_prefix = "FCPBProfileV1"; +import "common/v1/common.proto"; +import "profile/v1/model.proto"; + +service Profile { + rpc GetProfile(GetProfileRequest) returns (GetProfileResponse); + rpc SetDisplayName(SetDisplayNameRequest) returns (SetDisplayNameResponse); +} +message GetProfileRequest { + common.v1.UserId user_id = 1; +} +message GetProfileResponse { + Result result = 1; + enum Result { + OK = 0; + NOT_FOUND = 1; + } + // UserProfile, if found. + // + // Some fields may or may not be set, depending on the scope of request + // in the future. + UserProfile user_profile = 2; +} +message SetDisplayNameRequest { + // DisplayName is the new name to set. + string display_name = 1 ; + common.v1.Auth auth = 10; +} +message SetDisplayNameResponse { + Result result = 1; + enum Result { + OK = 0; + INVALID_DISPLAY_NAME = 1; + DENIED = 2; + } +} diff --git a/definitions/flipchat/protos/src/main/proto/push/v1/push_service.proto b/definitions/flipchat/protos/src/main/proto/push/v1/push_service.proto new file mode 100644 index 000000000..76e983f6d --- /dev/null +++ b/definitions/flipchat/protos/src/main/proto/push/v1/push_service.proto @@ -0,0 +1,58 @@ +syntax = "proto3"; +package flipchat.push.v1; +option go_package = "github.com/code-payments/flipchat-protobuf-api/generated/go/push/v1;pushpb"; +option java_package = "com.codeinc.flipchat.gen.push.v1"; +option objc_class_prefix = "FPBPushV1"; +import "common/v1/common.proto"; + +service Push { + // AddToken adds a push token associated with a user. + rpc AddToken(AddTokenRequest) returns (AddTokenResponse); + // DeleteToken removes a specific push token from a user. + // + // Deprecated: Use DeleteTokens intead + rpc DeleteToken(DeleteTokenRequest) returns (DeleteTokenResponse); + // DeleteTokens removes all push tokens within an app install for a user + rpc DeleteTokens(DeleteTokensRequest) returns (DeleteTokensResponse); +} +enum TokenType { + UNKNOWN = 0; + // FCM registration token for an Android device + FCM_ANDROID = 1; + // FCM registration token or an iOS device + FCM_APNS = 2; +} +message AddTokenRequest { + TokenType token_type = 1; + string push_token = 2 ; + common.v1.AppInstallId app_install = 3; + common.v1.Auth auth = 4; +} +message AddTokenResponse { + Result result = 1; + enum Result { + OK = 0; + INVALID_PUSH_TOKEN = 1; + } +} +message DeleteTokenRequest { + TokenType token_type = 1; + string push_token = 2 ; + common.v1.Auth auth = 3; +} +message DeleteTokenResponse { + Result result = 1; + enum Result { + OK = 0; + } +} +message DeleteTokensRequest { + common.v1.AppInstallId app_install = 1; + common.v1.Auth auth = 2; +} +message DeleteTokensResponse { + Result result = 1; + enum Result { + OK = 0; + } +} diff --git a/fastlane/Appfile b/fastlane/Appfile index 0cc86ca8a..286a09891 100644 --- a/fastlane/Appfile +++ b/fastlane/Appfile @@ -1,2 +1 @@ json_key_file(ENV["SERVICE_ACCOUNT_KEY_JSON"]) # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one -package_name("com.getcode") # e.g. com.krausefx.app diff --git a/fastlane/Fastfile b/fastlane/Fastfile index a7aa198f8..423288761 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -16,46 +16,53 @@ default_platform(:android) platform :android do - desc "Runs all the tests" - lane :test do - gradle(task: "test") + desc "Runs all the tests for Code" + lane :code_tests do + gradle(task: "app:testDebug") end - desc "Build and Deploy a new alpha version to the Google Play" - lane :deploy_alpha do - #puts "Patch version for this build will be " + ENV["BUILD_NUMBER"] - gradle( - task: "clean bundle", #"clean app:bundleRelease", - build_type: "release", - properties: { - #"versionPatch" => ENV["BUILD_NUMBER"], - "android.injected.signing.store.file" => "key/key", - "android.injected.signing.store.password" => ENV["STORE_PASSWORD"], - "android.injected.signing.key.alias" => ENV["KEY_ALIAS"], - "android.injected.signing.key.password" => ENV["KEY_PASSWORD"] - } - ) + desc "Runs all the tests for Flipchat" + lane :fc_tests do + gradle(task: "flipchatApp:testDebug") + end - validate_play_store_json_key( - json_key: ENV["SERVICE_ACCOUNT_KEY_JSON"] - ) + desc "Build and Deploy a new internal version of Code to the Google Play" + lane :deploy_code_internal do + #puts "Patch version for this build will be " + ENV["BUILD_NUMBER"] + gradle( + task: "clean app:bundle", #"clean app:bundleRelease", + build_type: "release", + properties: { + #"versionPatch" => ENV["BUILD_NUMBER"], + "android.injected.signing.store.file" => "key/key", + "android.injected.signing.store.password" => ENV["STORE_PASSWORD"], + "android.injected.signing.key.alias" => ENV["KEY_ALIAS"], + "android.injected.signing.key.password" => ENV["KEY_PASSWORD"] + } + ) - upload_to_play_store( - track: "alpha", - aab: Actions.lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH], - skip_upload_apk: true, - skip_upload_changelogs: true, - skip_upload_images: true, - mapping: Actions.lane_context[SharedValues::GRADLE_MAPPING_TXT_OUTPUT_PATH] - # mapping: mapping_file_exists() ? Actions.lane_context[SharedValues::GRADLE_MAPPING_TXT_OUTPUT_PATH] : nil - ) - end + validate_play_store_json_key( + json_key: ENV["SERVICE_ACCOUNT_KEY_JSON"] + ) - desc "Build and Deploy a new internal version to the Google Play" - lane :deploy_internal do + upload_to_play_store( + track: "internal", + aab: Actions.lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH], + skip_upload_apk: true, + skip_upload_changelogs: true, + skip_upload_images: true, + package_name: "com.getcode", + mapping: Actions.lane_context[SharedValues::GRADLE_MAPPING_TXT_OUTPUT_PATH] + # mapping: mapping_file_exists() ? Actions.lane_context[SharedValues::GRADLE_MAPPING_TXT_OUTPUT_PATH] : nil + ) + end + + + desc "Build and Deploy a new internal version of Flipchat to the Google Play Store" + lane :deploy_fc_internal do #puts "Patch version for this build will be " + ENV["BUILD_NUMBER"] gradle( - task: "clean bundle", #"clean app:bundleRelease", + task: "clean flipchatApp:bundle", #"clean app:bundleRelease", build_type: "release", properties: { #"versionPatch" => ENV["BUILD_NUMBER"], @@ -66,6 +73,17 @@ platform :android do } ) + bundletool( + ks_path: "key/key", + ks_password: ENV["STORE_PASSWORD"], + ks_key_alias: ENV["KEY_ALIAS"], + ks_key_alias_password: ENV["KEY_PASSWORD"], + bundletool_version: '1.10.0', + aab_path: Actions.lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH], + apk_output_path: "flipchatApp/build/outputs/apk/release", + verbose: true, + ) + validate_play_store_json_key( json_key: ENV["SERVICE_ACCOUNT_KEY_JSON"] ) @@ -76,6 +94,7 @@ platform :android do skip_upload_apk: true, skip_upload_changelogs: true, skip_upload_images: true, + package_name: "xyz.flipchat.app", mapping: Actions.lane_context[SharedValues::GRADLE_MAPPING_TXT_OUTPUT_PATH] # mapping: mapping_file_exists() ? Actions.lane_context[SharedValues::GRADLE_MAPPING_TXT_OUTPUT_PATH] : nil ) diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile new file mode 100644 index 000000000..860473d88 --- /dev/null +++ b/fastlane/Pluginfile @@ -0,0 +1,5 @@ +# Autogenerated by fastlane +# +# Ensure this file is checked in to source control! + +gem 'fastlane-plugin-bundletool' diff --git a/fastlane/README.md b/fastlane/README.md new file mode 100644 index 000000000..67ce7eba9 --- /dev/null +++ b/fastlane/README.md @@ -0,0 +1,48 @@ +fastlane documentation +---- + +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +```sh +xcode-select --install +``` + +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) + +# Available Actions + +## Android + +### android test + +```sh +[bundle exec] fastlane android test +``` + +Runs all the tests + +### android deploy_code_internal + +```sh +[bundle exec] fastlane android deploy_code_internal +``` + +Build and Deploy a new internal version of Code to the Google Play + +### android deploy_fc_internal + +```sh +[bundle exec] fastlane android deploy_fc_internal +``` + +Build and Deploy a new internal version of Flipchat to the Google Play Store + +---- + +This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/fastlane/report.xml b/fastlane/report.xml new file mode 100644 index 000000000..19904ccea --- /dev/null +++ b/fastlane/report.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/flipchatApp/.gitignore b/flipchatApp/.gitignore new file mode 100644 index 000000000..80efa742e --- /dev/null +++ b/flipchatApp/.gitignore @@ -0,0 +1,3 @@ +build/ +.gradle/ +google-services.json diff --git a/flipchatApp/build.gradle.kts b/flipchatApp/build.gradle.kts new file mode 100644 index 000000000..522444e0c --- /dev/null +++ b/flipchatApp/build.gradle.kts @@ -0,0 +1,247 @@ +import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension +import org.jetbrains.kotlin.cli.common.toBooleanLenient + +plugins { + id(Plugins.android_application) + id(Plugins.kotlin_android) + id(Plugins.kotlin_parcelize) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) + id(Plugins.androidx_navigation_safeargs) + id(Plugins.hilt) + id(Plugins.google_services) + id(Plugins.firebase_crashlytics) + id(Plugins.firebase_perf) + id(Plugins.bugsnag) + id(Plugins.secrets_gradle_plugin) + id(Plugins.versioning_gradle_plugin) +} + +val contributorsSigningConfig = ContributorsSignatory(rootProject) +val appNamespace = "${Android.flipchatNamespace}.app" + +android { + // static namespace + namespace = appNamespace + compileSdk = Android.compileSdkVersion + + defaultConfig { + versionCode = versioning.getVersionCode() + versionName = Packaging.Flipchat.versionName + applicationId = appNamespace + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + + buildConfigField("String", "MIXPANEL_API_KEY", "\"${tryReadProperty(rootProject.rootDir, "MIXPANEL_API_KEY")}\"") + buildConfigField("String", "KADO_API_KEY", "\"${tryReadProperty(rootProject.rootDir, "KADO_API_KEY")}\"") + buildConfigField("Boolean", "NOTIFY_ERRORS", "false") + } + + signingConfigs { + create("contributors") { + storeFile = contributorsSigningConfig.keystore + storePassword = contributorsSigningConfig.keystorePassword + keyAlias = contributorsSigningConfig.keyAlias + keyPassword = contributorsSigningConfig.keyPassword + } + } + + buildFeatures { + buildConfig = true + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.compose_compiler + } + + buildTypes { + getByName("release") { + resValue("string", "applicationId", appNamespace) + isMinifyEnabled = true + isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + getByName("debug") { + applicationIdSuffix = ".dev" + resValue("string", "applicationId", "${appNamespace}.dev") + signingConfig = signingConfigs.getByName("contributors") + + val debugMinifyEnabled = tryReadProperty(rootProject.rootDir, "DEBUG_MINIFY", "false").toBooleanLenient() ?: false + isMinifyEnabled = debugMinifyEnabled + isShrinkResources = debugMinifyEnabled + + if (debugMinifyEnabled) { + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + + configure { + mappingFileUploadEnabled = tryReadProperty(rootProject.rootDir, "DEBUG_CRASHLYTICS_UPLOAD", "false").toBooleanLenient() ?: false + } + } + } + + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) + isCoreLibraryDesugaringEnabled = true + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.RequiresOptIn" + ) + } + packaging { + resources.excludes.add("**/*.proto") + resources.excludes.add("META-INF/LICENSE.md") + resources.excludes.add("META-INF/LICENSE-notice.md") + } +} + +dependencies { + implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) + implementation(project(":services:flipchat:sdk")) + + implementation(project(":libs:datetime")) + implementation(project(":libs:locale")) + implementation(project(":libs:vibrator")) + implementation(project(":libs:encryption:ed25519")) + implementation(project(":libs:encryption:keys")) + implementation(project(":libs:encryption:mnemonic")) + implementation(project(":libs:encryption:utils")) + implementation(project(":libs:crypto:kin")) + implementation(project(":libs:currency")) + implementation(project(":libs:logging")) + implementation(project(":libs:messaging")) + implementation(project(":libs:network:exchange")) + implementation(project(":libs:network:connectivity")) + implementation(project(":libs:opengraph")) + implementation(project(":libs:permissions")) + implementation(project(":libs:quickresponse")) + implementation(project(":libs:requests")) + implementation(project(":ui:components")) + implementation(project(":ui:navigation")) + implementation(project(":ui:resources")) + implementation(project(":ui:theme")) + implementation(project(":vendor:tipkit:tipkit-m2")) + + coreLibraryDesugaring(Libs.android_desugaring) + + //standard libraries + implementation(Libs.kotlinx_collections_immutable) + implementation(Libs.kotlinx_serialization_json) + implementation(Libs.kotlinx_datetime) + implementation(Libs.androidx_core) + implementation(Libs.androidx_constraint_layout) + implementation(Libs.androidx_lifecycle_runtime) + implementation(Libs.androidx_lifecycle_viewmodel) + implementation(Libs.androidx_navigation_fragment) + implementation(Libs.androidx_navigation_ui) + + //hilt dependency injection + implementation(Libs.hilt) + implementation("androidx.webkit:webkit:1.12.1") + kapt(Libs.hilt_android_compiler) + kapt(Libs.hilt_compiler) + androidTestImplementation(Libs.hilt) + androidTestImplementation(Libs.hilt_android_test) + kaptAndroidTest(Libs.hilt_android_compiler) + testImplementation(Libs.hilt_android_test) + kaptTest(Libs.hilt_android_compiler) + + androidTestImplementation("io.mockk:mockk:1.13.12") + + //Jetpack compose + implementation(platform(Libs.compose_bom)) + implementation(Libs.compose_ui) + debugImplementation(Libs.compose_ui_tools) + implementation(Libs.compose_accompanist) + implementation(Libs.compose_foundation) + implementation(Libs.compose_material) + implementation(Libs.compose_materialIconsExtended) + implementation(Libs.compose_activities) + implementation(Libs.compose_view_models) + implementation(Libs.compose_livedata) + implementation(Libs.compose_navigation) + implementation(Libs.compose_paging) + implementation(Libs.compose_webview) + + implementation(Libs.androidx_biometrics) + + implementation(Libs.androidx_activity) + + // cameraX + implementation(Libs.androidx_camerax_core) + implementation(Libs.androidx_camerax_camera2) + implementation(Libs.androidx_camerax_lifecycle) + implementation(Libs.androidx_camerax_view) + + implementation(Libs.coil3) + implementation(Libs.coil3_network) + + implementation(Libs.androidx_browser) + implementation(Libs.androidx_constraint_layout_compose) + + implementation(Libs.rxjava) + implementation(Libs.rxandroid) + + implementation(Libs.slf4j) + implementation(Libs.grpc_android) + + implementation(platform(Libs.firebase_bom)) + implementation(Libs.firebase_analytics) + implementation(Libs.firebase_crashlytics) + implementation(Libs.firebase_messaging) + + implementation(Libs.hilt_nav_compose) + implementation(Libs.lib_phone_number_port) + implementation(Libs.mp_android_chart) + implementation(Libs.mixpanel) + + implementation(Libs.retrofit) + implementation(Libs.retrofit_converter) + implementation(Libs.okhttp_logging_interceptor) + + androidTestImplementation(Libs.androidx_test_runner) + androidTestImplementation(Libs.androidx_junit) + androidTestImplementation(Libs.junit) + androidTestImplementation(Libs.espresso_core) + androidTestImplementation(Libs.espresso_contrib) { + exclude(module = "protobuf-lite") + } + androidTestImplementation(Libs.espresso_intents) + implementation(Libs.androidx_room_runtime) + implementation(Libs.androidx_room_ktx) + implementation(Libs.androidx_room_rxjava3) + implementation(Libs.androidx_room_paging) + kapt(Libs.androidx_room_compiler) + + implementation(Libs.androidx_datastore) + + implementation(Libs.markwon_core) + implementation(Libs.markwon_linkify) + implementation(Libs.markwon_ext_strikethrough) + + implementation(Libs.play_service_auth) + implementation(Libs.play_service_auth_phone) + + implementation(Libs.timber) + implementation(Libs.bugsnag) + + implementation(Libs.haze) + + implementation(Libs.rinku_compose) +} \ No newline at end of file diff --git a/flipchatApp/proguard-rules.pro b/flipchatApp/proguard-rules.pro new file mode 100644 index 000000000..fb05d0053 --- /dev/null +++ b/flipchatApp/proguard-rules.pro @@ -0,0 +1,84 @@ +-optimizationpasses 5 +-dontusemixedcaseclassnames +-verbose +#-optimizations !code/simplification/arithmetic,!field/*,!class/merging/*,!code/allocation/variable +-obfuscationdictionary shuffled-dictionary.txt +-classobfuscationdictionary shuffled-dictionary.txt + +-keepclasseswithmembernames class * { + native ; +} +#-keepclasseswithmembers class * { +# public (android.content.Context, android.util.AttributeSet); +#} + +#-keepclasseswithmembers class * { +# public (android.content.Context, android.util.AttributeSet, int); +#} + +#-keepclassmembers class * extends android.app.Activity { +# public void *(android.view.View); +#} + +#-keepclassmembers enum * { +# public static **[] values(); +# public static ** valueOf(java.lang.String); +#} + +-keepclassmembers class **.R$* { + public static ; +} + +# Room +-keep class * extends androidx.room.RoomDatabase +-keep @androidx.room.Entity class * +-keep class net.sqlcipher.** { *; } + +## Code API +-keep class com.codeinc.gen.** {*;} +-keep class com.google.protobuf.** { *; } + +# Keep our scan classes that interact with native +-keep class com.kik.scan.** { *; } + +# BouncyCastle +-keep public class org.bouncycastle.** # Refine this further! +-keepclassmembers class org.bouncycastle.crypto.** { + ; +} + +-assumenosideeffects class android.util.Log { + public static int v(...); + public static int i(...); + public static int w(...); + public static int d(...); + public static int e(...); +} + +-keep public class * extends java.lang.Exception +-keep public class * extends com.getcode.network.repository.ErrorSubmitIntent +-keep public class * extends com.getcode.network.repository.ErrorSubmitIntentException +-keep public class * extends com.getcode.network.repository.WithdrawException +-keep public class * extends com.getcode.network.repository.FetchUpgradeableIntentsException +-keep public class * extends com.getcode.network.repository.AirdropException + +# https://github.com/firebase/firebase-android-sdk/issues/3688 +-keep class org.json.** { *; } +-keepclassmembers class org.json.** { *; } + +# Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items). + -keep,allowobfuscation,allowshrinking interface retrofit2.Call + -keep,allowobfuscation,allowshrinking class retrofit2.Response + + # With R8 full mode generic signatures are stripped for classes that are not + # kept. Suspend functions are wrapped in continuations where the type argument + # is used. + -keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation + +# libsodium +-keep class com.ionspin.kotlin.crypto.** { *; } +-keep class com.sun.jna.** { *; } +-dontwarn java.awt.Component +-dontwarn java.awt.GraphicsEnvironment +-dontwarn java.awt.HeadlessException +-dontwarn java.awt.Window \ No newline at end of file diff --git a/flipchatApp/shuffled-dictionary.txt b/flipchatApp/shuffled-dictionary.txt new file mode 100644 index 000000000..1c04f878b --- /dev/null +++ b/flipchatApp/shuffled-dictionary.txt @@ -0,0 +1,811 @@ +Isht +BQUQ +lYNE +YWWv +zhIz +dRko +ihUe +duhS +oRft +jWAX +KLsZ +xbYj +NxZs +ercX +Zikf +jygi +xRWw +BBdO +hOvC +HEqd +QlDS +zchV +mrtd +tAMc +beae +NzVI +WGdk +mlen +mTqY +zOWI +yDek +WkdC +EvHb +fjdG +tXQX +lysK +tJrI +nTyR +QoId +teja +tBns +WdjJ +rzBH +CsrS +rGDS +mgig +lKwS +dsew +PEIO +Iagi +FzdP +jyBf +dkuR +gwNv +GoKt +bHKA +YPEH +sRnT +JfEF +ifmJ +zyoT +dgLf +XeSR +MbvH +EviK +etia +MrSO +sXtv +Bgfm +lRiu +RwJl +tUXp +ZwyR +daJE +Duhs +qXtR +QXRy +NKZp +EVoJ +APSm +YgnC +Xsxv +VlNx +fYSo +NWos +JpCK +bOOW +PpQM +xMex +kZmG +fgZr +Jxmu +BVUp +LBel +WrBi +JFtE +jMcJ +IiRY +rmmT +LyLw +GxwL +xwEO +MDtQ +tXXA +yJqI +XWEk +IchN +snux +ycvY +vrwB +juTi +wISO +OIcx +enkB +ELNl +ymTW +ZAhU +NZjR +bYGt +EHts +lzjc +FUZJ +oGFN +GRql +kKUH +iRfG +kKBN +gavV +CtCD +Jlcd +rkLy +RGmg +sacF +UsUf +rJfG +mLPF +IsTH +gYXw +XNZg +QeWt +zONE +osyG +cLMf +RhpR +oaoi +vaPm +peYL +DGbn +IWRQ +FOMD +zTcd +Hvbw +vqPu +lWCZ +ZwrR +IPnJ +BDfW +ZLPb +mdFn +ZKGS +dOQK +vOTL +nGQY +aoOn +sJwn +DrgD +bIwW +zJIB +Fdra +COnA +EULK +WlWi +bAif +LgwE +yMjR +HdjF +vCmv +LyGj +lGkZ +FIHF +MTiY +YHSv +bsEG +qluJ +PZRG +fXfC +nscO +UIxm +rIyJ +YUDD +ffjW +GXpA +bQji +IAyQ +FZXu +kclk +ryYv +DZOO +rNcr +xLYb +BZJL +RfqD +DOnu +Skiq +VFQs +fuzj +cYxv +FGTC +VWfA +GcgM +EffG +YsaB +Kvpz +uMqE +iBWV +RNlt +NSDn +VpKD +IOhC +KGja +fznE +LldB +KTWJ +SGiI +tGCd +mKFS +kpjE +inNw +YFhv +IoDg +xPxa +ZSGd +VQAo +WaFi +FJOL +bEPM +OraL +CosS +Qpuw +ugST +yNMr +Esbm +FYIB +nXLw +IAJC +wvIu +rEdl +rAIt +RbZu +BGAB +JvuF +ChCH +ywvf +GwoF +lmGe +oGcJ +tGtZ +wkSy +uXTa +OmAD +FPNM +yOYL +eiJD +GGzr +rFrR +Wtyr +smhO +Qusu +MHBp +gYfm +AQMx +JVnn +dcdI +cVrJ +tTwl +QRJf +oyRp +ZaOh +Dmjk +wDaF +vVWP +twIb +FajC +orbD +UTlY +yJOD +FTaL +tiNW +xSNV +lOiN +KrqV +oHaN +JCWM +EKhf +rbdj +dipY +mkjj +WELS +FwtI +zMLd +PyQf +yEtV +xClf +hrPJ +Yftz +HlbL +cyha +oynP +ncsf +atCi +TZCr +iwXC +WuLG +GdVK +OLYf +FgLC +AKUb +nKYb +BWnv +nkhv +Omrp +gsSt +wJnx +HZHh +dLna +PmVN +juAs +MsNP +kjBm +WoRy +ujpz +WskB +ZxUN +DjCe +fyOr +JQIG +utyB +ifvv +uDEA +usVw +OCSt +RCtd +LYDI +HejM +SwhM +gKcH +Bahm +hUTr +SzWQ +fxar +PAdX +icPy +eCYN +SucB +Yjnf +sdxY +sogd +wQCZ +KbLO +wSbt +kLsz +OkpF +MPmo +YcUL +TPJF +SgMm +ekQU +FKYn +qZQR +Fdtt +XVNw +GwFc +XauS +vQrD +jmoJ +UzaQ +zsKx +ndeh +FeuU +teGY +Trkd +pRLL +zeHL +SGPZ +nUti +qQws +BLbX +yqHy +lmFr +TMYU +NlbJ +bRAt +bCFJ +pAuF +fcrw +SVWh +sSde +DAGR +AduC +hVYM +fUSZ +BbRe +RrFF +ZKFL +UtSD +BVqP +UBfb +EgjY +hpxM +cyQy +MLSV +fpdI +eBuR +Kyua +tYDV +CNdO +ZQUw +yDMT +RHAZ +KSms +lgaB +bxzl +YlIs +MVGF +ENts +dgRD +SybV +Mpne +DLah +anUP +nbFc +KZDx +VhDU +UnCj +mFPU +QpML +VKRz +sMQB +JzdA +ZtHp +AkzT +LdLP +CBrv +CtHu +vTfo +uwce +ihuY +xqIU +rvyG +YIeY +nhXV +PvbA +gahw +QTPg +Tcxn +WDuH +HbXF +AJpF +kvzs +VhWQ +dvhm +CGnG +ppzZ +Azky +sWsN +UrPx +Znvg +ngaV +Frhs +lpAR +RpIm +eHlK +UvCX +jHQK +bRCz +Ntid +vVcM +wdim +mttW +qtHO +hwTH +DxKD +shKW +vgBc +oWSA +LeTC +aweS +tsjN +BXOK +vgrh +CSvq +rwUV +cnhU +VFuP +qzBz +Nbay +mKib +TQOI +IxTd +ufEE +uHls +pSTp +JDNU +qHMb +wyBD +Uxof +hPWy +wyZc +arfA +AoZs +qpkG +duyB +teoC +BHLw +rEUj +MMmn +tbUY +jcMO +RbjW +ofCJ +CKNe +YxsR +ozgG +UgOi +JBJO +PZkK +kCIL +JtNn +TVAI +zQsm +VZgL +rukZ +UIEr +Gaoa +tFRW +YPAz +FoKp +hxOK +Zrbo +PbMj +mkaj +Jfvu +RVRh +keIJ +lXOG +HkcV +sbFt +wFcX +iaHo +ncGe +vLSU +PvJz +XlId +Iqbc +FNHp +OauE +Cnsp +cYdn +dTjy +ARVt +GjAP +NeOb +QBRs +teHM +NNFq +pQtt +Ydde +nRZl +jVjd +LGOI +NYIF +PwqS +Twdw +zLme +xomi +PEYK +OjFT +JnFx +vMSR +MtCN +Vmbv +CHXq +hVyA +gzBT +PmnH +cAqk +Fcie +llOf +uVTH +goDD +IEJJ +kGDH +xJPI +LIce +ZtnA +NatG +fXys +hikO +jeXD +nHEB +uVNP +OAJq +gdhL +yPaj +MyJn +wvXg +WuvS +FMXw +ZtSa +cTtl +ioBO +fvdC +ZTbi +ZSon +CYoZ +pODY +HhcT +ABlS +sWns +iDwj +cPNL +DMIa +Hexg +nYen +lPYb +nSND +QDYO +gGIC +Sgvg +yIpe +SiFd +WXRO +IxHN +bkSq +dlVA +LKYw +muTP +HRnO +KiMy +uAzD +alTU +oEID +EmRn +BAzw +LFgd +qaUi +crwT +OIbV +nnET +pZpN +GXiC +aPKz +pwdU +ijjR +Hohs +DbdX +FyRB +fmkK +GFkw +EUuW +LAFO +TbwB +VEmz +Uvdu +ypFl +IBzE +bzWY +KUOp +JFIw +VOTC +nIJJ +thMd +zCuy +ZkaW +vNvX +mUfe +iCoZ +ZpvJ +fbsS +Cfzj +orEO +KpTn +lzzD +JBcp +ISWa +DAfh +HlJM +BdsO +aytI +LSvL +BarO +Wnth +cQyc +qwUp +Fwgx +QXaW +ZiVt +epbZ +VVVC +xtVZ +lPKP +YpCp +wSqt +ssNm +wFLC +NulV +rsqe +Rgde +XKRR +Ludw +mYXR +STdD +vQPY +OXnG +uvkJ +GoHg +nROB +duCc +Qjrw +WZUp +vvVZ +Rvtc +VyFa +LLdY +qlPD +ueda +ClMc +bMVB +zmXD +asSs +wyaY +bJRq +fIsz +yhWi +fZXO +qfLK +HBIx +ReOh +RbVD +Zfeo +LNOU +oHYO +xOjl +XnVx +SwJK +foCJ +PxwF +JmoH +rqKo +rBjl +sNJV +GcFn +Moky +WzQl +WTzB +AIjG +SDfe +dZXz +VIDB +Zlww +UzuA +nXUD +bUrp +QNdb +FkSO +imLC +WqLj +qSbN +mfvq +bPog +uYVI +CKbj +BNcy +RLng +GHjM +FFwD +qSfw +ZvlI +JITU +rHGC +Wigr +zdHB +Orfj +QKgP +oVhJ +SOml +kEuj +GrKj +lVMG +xTxC +HCUN +ZWTO +vclb +wWcc +dckr +vevq +pHjd +zBFk +oneP +ZhKt +ABmP +oJxu +QPfa +yjEq +oPvN +ZsAI +waDy +VKNw +QzHV +KPNY +rhwm diff --git a/flipchatApp/src/debug/AndroidManifest.xml b/flipchatApp/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..0775aa4c4 --- /dev/null +++ b/flipchatApp/src/debug/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/flipchatApp/src/debug/res/values/ic_launcher_background.xml b/flipchatApp/src/debug/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..fe71b618f --- /dev/null +++ b/flipchatApp/src/debug/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #C372FF + diff --git a/flipchatApp/src/debug/res/values/strings.xml b/flipchatApp/src/debug/res/values/strings.xml new file mode 100644 index 000000000..5587c526f --- /dev/null +++ b/flipchatApp/src/debug/res/values/strings.xml @@ -0,0 +1,3 @@ + + Flipchat Dev + \ No newline at end of file diff --git a/flipchatApp/src/debug/res/xml/authenticator.xml b/flipchatApp/src/debug/res/xml/authenticator.xml new file mode 100644 index 000000000..494bd7566 --- /dev/null +++ b/flipchatApp/src/debug/res/xml/authenticator.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/flipchatApp/src/main/AndroidManifest.xml b/flipchatApp/src/main/AndroidManifest.xml new file mode 100644 index 000000000..a5b8cac79 --- /dev/null +++ b/flipchatApp/src/main/AndroidManifest.xml @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/flipchatApp/src/main/ic_launcher-playstore.png b/flipchatApp/src/main/ic_launcher-playstore.png new file mode 100644 index 000000000..cc79f6603 Binary files /dev/null and b/flipchatApp/src/main/ic_launcher-playstore.png differ diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/App.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/App.kt new file mode 100644 index 000000000..cdfa71251 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/App.kt @@ -0,0 +1,191 @@ +package xyz.flipchat.app + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.currentOrThrow +import cafe.adriel.voyager.transitions.SlideTransition +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.core.BottomSheetNavigator +import com.getcode.navigation.core.CombinedNavigator +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.extensions.getActivityScopedViewModel +import com.getcode.navigation.transitions.SheetSlideTransition +import com.getcode.theme.LocalCodeColors +import com.getcode.ui.components.OnLifecycleEvent +import com.getcode.ui.components.bars.BottomBarContainer +import com.getcode.ui.components.bars.TopBarContainer +import com.getcode.ui.components.bars.rememberBarManager +import com.getcode.ui.components.restrictions.RestrictionType +import com.getcode.ui.decor.ScrimSupport +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.utils.getActivity +import dev.bmcreations.tipkit.TipScaffold +import dev.bmcreations.tipkit.engines.TipsEngine +import dev.theolm.rinku.DeepLink +import dev.theolm.rinku.compose.ext.DeepLinkListener +import xyz.flipchat.app.features.home.HomeViewModel +import xyz.flipchat.app.features.payments.PaymentScaffold +import xyz.flipchat.app.theme.FlipchatTheme +import xyz.flipchat.app.ui.LocalUserManager +import xyz.flipchat.app.ui.navigation.AppScreenContent +import xyz.flipchat.app.ui.navigation.MainRoot +import xyz.flipchat.app.util.DeeplinkType + +@Composable +fun App( + tipsEngine: TipsEngine, +) { + val homeViewModel = getActivityScopedViewModel() + val router = homeViewModel.router + val context = LocalContext.current + + //We are obtaining deep link here, in case we want to allow for some amount of deep linking when not + //authenticated. Currently we will require authentication to see anything, but can be changed in future. + var deepLink by remember { mutableStateOf(null) } + var loginRequest by remember { mutableStateOf(null) } + + DeepLinkListener { + val type = router.processType(it) + if (type is DeeplinkType.Login) { + loginRequest = type.entropy + return@DeepLinkListener + } + deepLink = it + } + + val userManager = LocalUserManager.currentOrThrow + val userState by userManager.state.collectAsState() + + FlipchatTheme { + val barManager = rememberBarManager() + AppScreenContent { + AppNavHost { + val codeNavigator = LocalCodeNavigator.current + TipScaffold(tipsEngine = tipsEngine) { + ScrimSupport { + CodeScaffold { innerPaddingModifier -> + PaymentScaffold { + Navigator( + screen = MainRoot { deepLink }, + ) { navigator -> + LaunchedEffect(navigator.lastItem) { + // update global navigator for platform access to support push/pop from a single + // navigator current + codeNavigator.screensNavigator = navigator + } + + Box( + modifier = Modifier + .padding(innerPaddingModifier) + ) { + SlideTransition(navigator) + } + + LaunchedEffect(deepLink) { + if (codeNavigator.lastItem !is MainRoot) { + if (deepLink != null) { + val screenSet = router.processDestination(deepLink) + if (screenSet.isNotEmpty()) { + codeNavigator.replaceAll(screenSet) + } + } + } + } + + LaunchedEffect(loginRequest) { + loginRequest?.let { entropy -> + homeViewModel.handleLoginEntropy( + entropy, + onSwitchAccounts = { + loginRequest = null + context.getActivity()?.let { + homeViewModel.logout(it) { + codeNavigator.replaceAll(ScreenRegistry.get(NavScreenProvider.Login.Home(entropy))) + } + } + }, + onCancel = { + loginRequest = null + } + ) + } + } + + LaunchedEffect(userState.isTimelockUnlocked) { + if (userState.isTimelockUnlocked) { + codeNavigator.replaceAll(ScreenRegistry.get(NavScreenProvider.AppRestricted(RestrictionType.TIMELOCK_UNLOCKED))) + } + } + + OnLifecycleEvent { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + homeViewModel.onAppOpen() + } + + Lifecycle.Event.ON_STOP, + Lifecycle.Event.ON_DESTROY -> { + homeViewModel.closeStream() + } + + else -> Unit + } + } + } + } + } + } + } + } + } + TopBarContainer(barManager.barMessages) + BottomBarContainer(barManager.barMessages) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun AppNavHost(content: @Composable () -> Unit) { + var combinedNavigator by remember { + mutableStateOf(null) + } + BottomSheetNavigator( + modifier = Modifier.fillMaxSize(), + sheetBackgroundColor = LocalCodeColors.current.background, + sheetContentColor = LocalCodeColors.current.onBackground, + sheetContent = { sheetNav -> + combinedNavigator = combinedNavigator?.apply { sheetNavigator = sheetNav } + ?: CombinedNavigator(sheetNav) + combinedNavigator?.let { + CompositionLocalProvider(LocalCodeNavigator provides it) { + SheetSlideTransition(navigator = it) + } + } + + }, + onHide = com.getcode.services.manager.ModalManager::clear + ) { sheetNav -> + combinedNavigator = + combinedNavigator?.apply { sheetNavigator = sheetNav } ?: CombinedNavigator(sheetNav) + combinedNavigator?.let { + CompositionLocalProvider(LocalCodeNavigator provides it) { + content() + } + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/FlipchatApp.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/FlipchatApp.kt new file mode 100644 index 000000000..c4a984187 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/FlipchatApp.kt @@ -0,0 +1,66 @@ +package xyz.flipchat.app + +import android.app.Application +import androidx.appcompat.app.AppCompatDelegate +import com.bugsnag.android.Bugsnag +import com.getcode.crypt.MnemonicCache +import com.getcode.utils.ErrorUtils +import com.getcode.utils.trace +import com.google.firebase.Firebase +import com.google.firebase.crashlytics.crashlytics +import com.google.firebase.initialize +import dagger.hilt.android.HiltAndroidApp +import io.reactivex.rxjava3.plugins.RxJavaPlugins +import timber.log.Timber +import xyz.flipchat.app.auth.AuthManager +import javax.inject.Inject + +@HiltAndroidApp +class FlipchatApp : Application() { + + @Inject + lateinit var authManager: AuthManager + + override fun onCreate() { + super.onCreate() + + if (BuildConfig.DEBUG) { + Timber.plant(object : Timber.DebugTree() { + override fun createStackElementTag(element: StackTraceElement): String { + val elementTag = super.createStackElementTag(element) + .orEmpty() + .split("$") + .filter { it.isNotEmpty() } + .take(2) + .joinToString(" ") + .replace("_", " ") + + val methodName = element.methodName + .split("$") + .firstOrNull() + .orEmpty() + + return String.format( + "%s | %s ", + elementTag, + methodName + ) + } + }) + } else { + Bugsnag.start(this) + } + + RxJavaPlugins.setErrorHandler { + ErrorUtils.handleError(it) + } + + Firebase.initialize(this) + Firebase.crashlytics.setCrashlyticsCollectionEnabled(BuildConfig.NOTIFY_ERRORS || !BuildConfig.DEBUG) + MnemonicCache.init(this) + authManager.init { trace("NaCl init") } + + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + trace("app onCreate end") + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/MainActivity.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/MainActivity.kt new file mode 100644 index 000000000..f6808c3ff --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/MainActivity.kt @@ -0,0 +1,130 @@ +package xyz.flipchat.app + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.os.Process.killProcess +import android.os.Process.myPid +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.CompositionLocalProvider +import androidx.fragment.app.FragmentActivity +import com.getcode.libs.opengraph.LocalOpenGraphParser +import com.getcode.libs.opengraph.OpenGraphParser +import com.getcode.network.client.Client +import com.getcode.network.exchange.ExchangeNull +import com.getcode.network.exchange.LocalExchange +import xyz.flipchat.app.ui.LocalUserManager +import com.getcode.util.resources.LocalResources +import com.getcode.util.resources.ResourceHelper +import com.getcode.util.vibration.LocalVibrator +import com.getcode.util.vibration.Vibrator +import com.getcode.utils.CurrencyUtils +import com.getcode.utils.LocalCurrencyUtils +import com.getcode.utils.network.LocalNetworkObserver +import com.getcode.utils.network.NetworkConnectivityListener +import com.google.firebase.crashlytics.FirebaseCrashlytics +import dagger.hilt.android.AndroidEntryPoint +import dev.bmcreations.tipkit.engines.TipsEngine +import dev.theolm.rinku.compose.ext.Rinku +import xyz.flipchat.app.beta.Labs +import xyz.flipchat.app.ui.LocalLabs +import xyz.flipchat.services.LocalPaymentController +import xyz.flipchat.services.PaymentController +import xyz.flipchat.services.billing.BillingClient +import xyz.flipchat.services.billing.LocalBillingClient +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject +import kotlin.system.exitProcess + +@AndroidEntryPoint +class MainActivity : FragmentActivity() { + + @Inject + lateinit var resources: ResourceHelper + + @Inject + lateinit var tipsEngine: TipsEngine + + @Inject + lateinit var networkObserver: NetworkConnectivityListener + + @Inject + lateinit var currencyUtils: CurrencyUtils + + @Inject + lateinit var vibrator: Vibrator + + @Inject + lateinit var userManager: UserManager + + @Inject + lateinit var client: Client + + @Inject + lateinit var paymentController: PaymentController + + @Inject + lateinit var betaFeatures: Labs + + @Inject + lateinit var iapController: BillingClient + + @Inject + lateinit var openGraphParser: OpenGraphParser + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + handleUncaughtException() + enableEdgeToEdge() + + setContent { + CompositionLocalProvider( + LocalResources provides resources, + LocalNetworkObserver provides networkObserver, + LocalExchange provides ExchangeNull(), + LocalCurrencyUtils provides currencyUtils, + LocalVibrator provides vibrator, + LocalUserManager provides userManager, + LocalPaymentController provides paymentController, + LocalLabs provides betaFeatures, + LocalBillingClient provides iapController, + LocalOpenGraphParser provides openGraphParser, + ) { + Rinku { + App(tipsEngine = tipsEngine) + } + } + } + } + + override fun onResume() { + super.onResume() + client.startTimer() + iapController.connect() + } + + override fun onStop() { + super.onStop() + client.stopTimer() + } +} + +private fun Activity.handleUncaughtException() { + val crashedKey = "isCrashed" + if (intent.getBooleanExtra(crashedKey, false)) return + Thread.setDefaultUncaughtExceptionHandler { _, throwable -> + if (BuildConfig.DEBUG) throw throwable + + FirebaseCrashlytics.getInstance().recordException(throwable) + + val intent = Intent(this, MainActivity::class.java).apply { + putExtra(crashedKey, true) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) + finish() + killProcess(myPid()) + exitProcess(2) + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/auth/AuthManager.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/auth/AuthManager.kt new file mode 100644 index 000000000..9e6816b68 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/auth/AuthManager.kt @@ -0,0 +1,304 @@ +package xyz.flipchat.app.auth + +import android.annotation.SuppressLint +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import com.bugsnag.android.Bugsnag +import com.getcode.ed25519.Ed25519 +import com.getcode.model.ID +import xyz.flipchat.app.util.AccountUtils +import com.getcode.services.db.Database +import com.getcode.services.utils.token +import com.getcode.utils.ErrorUtils +import com.getcode.utils.TraceType +import com.getcode.utils.base58 +import com.getcode.utils.encodeBase64 +import com.getcode.utils.trace +import com.getcode.vendor.Base58 +import com.google.firebase.Firebase +import com.google.firebase.messaging.FirebaseMessaging +import com.google.firebase.messaging.messaging +import com.ionspin.kotlin.crypto.LibsodiumInitializer +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import xyz.flipchat.FlipchatServices +import xyz.flipchat.app.BuildConfig +import xyz.flipchat.app.beta.Labs +import xyz.flipchat.app.util.UserIdResult +import xyz.flipchat.controllers.AuthController +import xyz.flipchat.controllers.ProfileController +import xyz.flipchat.controllers.PushController +import xyz.flipchat.services.user.AuthState +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AuthManager @Inject constructor( + @ApplicationContext private val context: Context, + private val authController: AuthController, + private val profileController: ProfileController, + private val userManager: UserManager, + private val pushController: PushController, + private val betaFlags: Labs, + private val notificationManager: NotificationManagerCompat, +// private val balanceController: BalanceController, +// private val notificationCollectionHistory: NotificationCollectionHistoryController, +// private val analytics: AnalyticsService, +// private val mixpanelAPI: MixpanelAPI +) : CoroutineScope by CoroutineScope(Dispatchers.IO) { + private var softLoginDisabled: Boolean = false + + companion object { + private const val TAG = "AuthManager" + internal fun taggedTrace(message: String, type: TraceType = TraceType.Log, cause: Throwable? = null) { + trace(message = message, type = type, tag = TAG, error = cause) + } + } + + @SuppressLint("CheckResult") + fun init(onInitialized: () -> Unit = { }) { + launch { + val token = AccountUtils.getToken(context)?.token + softLogin(token.orEmpty()) + .onSuccess { LibsodiumInitializer.initializeWithCallback(onInitialized) } + .onFailure(ErrorUtils::handleError) + } + } + + private suspend fun softLogin(entropyB64: String): Result { + if (softLoginDisabled) return Result.failure(Throwable("Disabled")) + return login(entropyB64, isSoftLogin = true) + } + + private fun setupAsNew(): String { + val entropyB64 = userManager.entropy + return if (entropyB64 == null) { + val seedB64 = Ed25519.createSeed16().encodeBase64() + userManager.establish(seedB64) + return seedB64 + } else { + entropyB64 + } + } + + suspend fun createAccount(): Result { + val entropy = setupAsNew() + FlipchatServices.openDatabase(context, entropy) + return authController.createAccount() + .onSuccess { userId -> + AccountUtils.addAccount( + context = context, + name = "Flipchat User", + password = userId.base58, + token = entropy, + isUnregistered = true + ) + userManager.set(userId) + userManager.set(AuthState.Unregistered) + profileController.getUserFlags() + }.onFailure { + it.printStackTrace() + clearToken() + } + } + + + suspend fun register(displayName: String): Result { + val entropyB64 = userManager.entropy ?: setupAsNew() + if (entropyB64.isEmpty()) { + taggedTrace("provided entropy was empty", type = TraceType.Error) + userManager.clear() + return Result.failure(Throwable("Provided entropy was empty")) + } + + softLoginDisabled = true + + FlipchatServices.openDatabase(context, entropyB64) + + // if we are in an unregistered state attempting to register + // it means the user account was setup on device and a public key registered on server. + // in this case, we simply need to set the display name on server to flip `is_registered`. + if (userManager.authState is AuthState.Unregistered) { + userManager.set(displayName = displayName) + + return profileController.setDisplayName(displayName) + .onSuccess { + AccountUtils.updateAccount( + context = context, + name = displayName, + ) + userManager.set(displayName = displayName) + userManager.set(AuthState.LoggedIn) + profileController.getUserFlags() + savePrefs() + } + .map { userManager.userId!! } + } else { + return authController.register(displayName) + .onSuccess { userId -> + if (userManager.authState is AuthState.Unregistered) { + AccountUtils.updateAccount( + context = context, + name = displayName, + ) + } else { + AccountUtils.addAccount( + context = context, + name = displayName, + password = userId.base58, + token = entropyB64, + isUnregistered = false, + ) + userManager.set(userId = userId) + } + userManager.set(displayName = displayName) + userManager.set(AuthState.LoggedIn) + profileController.getUserFlags() + savePrefs() + } + .onFailure { + it.printStackTrace() + softLoginDisabled = false + clearToken() + } + } + } + + suspend fun login( + entropyB64: String, + isSoftLogin: Boolean = false, + rollbackOnError: Boolean = false + ): Result { + taggedTrace("Login: isSoftLogin: $isSoftLogin, rollbackOnError: $rollbackOnError") + + if (entropyB64.isEmpty()) { + taggedTrace("provided entropy was empty", type = TraceType.Error) + userManager.clear() + return Result.failure(Throwable("Provided entropy was empty")) + } + + FlipchatServices.openDatabase(context, entropyB64) + + val originalEntropy = userManager.entropy + userManager.establish(entropy = entropyB64) + userManager.set(AuthState.LoggedInAwaitingUser) + + if (!isSoftLogin) { + loginAnalytics() + } + + if (!isSoftLogin) softLoginDisabled = true + + val lookup = AccountUtils.getUserId(context) + + val ret = if (isSoftLogin) { + when (lookup) { + is UserIdResult.Registered -> Result.success(Base58.decode(lookup.userId).toList()) + is UserIdResult.Unregistered -> Result.success(Base58.decode(lookup.userId).toList()) + null -> Result.failure(Throwable("No user Id found")) + } + } else { + authController.login() + } + + return ret + .map { it to profileController.getProfile(it) } + .map { (id, profileResult) -> + id to profileResult.getOrNull()?.displayName + } + .onSuccess { (userId, displayName) -> + if (!isSoftLogin) { + AccountUtils.addAccount( + context = context, + name = displayName ?: "Flipchat User", + password = userId.base58, + token = entropyB64, + isUnregistered = false, + ) + } + + userManager.set(userId = userId) + if (displayName != null) { + userManager.set(displayName = displayName) + } + + profileController.getUserFlags() + .onSuccess { flags -> + userManager.set(flags) + userManager.set(authState = if (flags?.isRegistered == true) AuthState.LoggedIn else AuthState.Unregistered) + }.onFailure { + taggedTrace("Failed to get user flags", type = TraceType.Error, cause = it) + userManager.set(authState = AuthState.Unregistered) + } + + savePrefs() + } + .onFailure { + it.printStackTrace() + if (rollbackOnError) { + login( + originalEntropy.orEmpty(), + isSoftLogin, + rollbackOnError = false + ) + } else { + logout(context) + clearToken() + } + }.map { it.first } + } + + suspend fun deleteAndLogout(context: Context): Result { + //todo: add account deletion + return logout(context) + } + + suspend fun logout(context: Context): Result { + return AccountUtils.removeAccounts(context).toFlowable() + .to { runCatching { it.firstOrError().blockingGet() } } + .map { clearToken() } + .map { Result.success(Unit) } + } + + private fun loginAnalytics() { + taggedTrace("analytics login event") +// analytics.login( +// ownerPublicKey = owner.getPublicKeyBase58(), +// autoCompleteCount = 0, +// inputChangeCount = 0 +// ) + } + + private suspend fun clearToken() { + FirebaseMessaging.getInstance().deleteToken() + pushController.deleteTokens() + notificationManager.cancelAll() + Database.close() + userManager.clear() + Database.delete(context) + betaFlags.reset() + if (!BuildConfig.DEBUG) Bugsnag.setUser(null, null, null) + } + + private suspend fun savePrefs() { + updateFcmToken() + } + + @SuppressLint("CheckResult") + private suspend fun updateFcmToken() { + val pushToken = Firebase.messaging.token() ?: return + pushController.addToken(pushToken) + .onSuccess { + trace("push token updated", type = TraceType.Silent) + }.onFailure { + trace(message = "Failure updating push token", error = it) + } + } + + sealed class AuthManagerException : Exception() { + class TimelockUnlockedException : AuthManagerException() + } +} diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/beta/Labs.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/beta/Labs.kt new file mode 100644 index 000000000..77a12767e --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/beta/Labs.kt @@ -0,0 +1,28 @@ +package xyz.flipchat.app.beta + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +interface Labs { + fun set(flag: Lab, value: Boolean) + suspend fun get(flag: Lab): Boolean + fun observe(flag: Lab): StateFlow + fun observe(): StateFlow> + fun reset(flag: Lab) + fun reset() +} + +object NoOpLabs: Labs { + override fun set(flag: Lab, value: Boolean) = Unit + + override suspend fun get(flag: Lab): Boolean = false + + override fun observe(flag: Lab): StateFlow = MutableStateFlow(false) + + override fun observe(): StateFlow> = + MutableStateFlow(Lab.entries.map { BetaFeature(it, it.default) }) + + override fun reset(flag: Lab) = Unit + override fun reset() = Unit + +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/beta/LabsController.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/beta/LabsController.kt new file mode 100644 index 000000000..264e729af --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/beta/LabsController.kt @@ -0,0 +1,169 @@ +package xyz.flipchat.app.beta + +import android.content.Context +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.preferencesDataStoreFile +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +sealed interface Lab { + val key: String + val default: Boolean + val launched: Boolean + + data object ReplyToMessage : Lab { + override val key = "pref_reply_enabled" + override val default: Boolean = true + override val launched: Boolean = true + } + + data object FollowerMode : Lab { + override val key: String = "pref_follower_mode_enabled" + override val default: Boolean = true + override val launched: Boolean = true + } + + data object StartChatAtUnread : Lab { + override val key: String = "pref_start_at_unread_enabled" + override val default: Boolean = true + override val launched: Boolean = true + } + + data object RoomNameChanges : Lab { + override val key: String = "pref_room_name_changes_enabled" + override val default: Boolean = true + override val launched: Boolean = true + } + + data object DeleteMessage : Lab { + override val key: String = "delete_message_enabled" + override val default: Boolean = true + override val launched: Boolean = true + } + + data object OpenCloseRoom : Lab { + override val key: String = "open_close_room_enabled" + override val default: Boolean = true + override val launched: Boolean = true + } + + data object Tipping : Lab { + override val key: String = "tipping_enabled" + override val default: Boolean = true + override val launched: Boolean = true + } + + data object LinkImages : Lab { + override val key: String = "link_image_preview_enabled" + override val default: Boolean = false + override val launched: Boolean = false + } + + companion object { + val entries = listOf( + ReplyToMessage, + FollowerMode, + StartChatAtUnread, + RoomNameChanges, + DeleteMessage, + OpenCloseRoom, + Tipping + ) + + internal fun byKey(key: Preferences.Key<*>): Lab? { + return entries.firstOrNull { it.key == key.name } + } + } +} + +data class BetaFeature( + val flag: Lab, + val enabled: Boolean, +) + +private val Lab.preferenceKey + get() = booleanPreferencesKey(key) + +class LabsController @Inject constructor( + @ApplicationContext context: Context, +) : Labs { + private val dataScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val betaFlags = PreferenceDataStoreFactory.create( + corruptionHandler = ReplaceFileCorruptionHandler( + produceNewData = { emptyPreferences() } + ), + migrations = listOf(), + scope = dataScope, + produceFile = { context.preferencesDataStoreFile("beta-flags") } + ) + + init { + // reset launched flags + Lab.entries + .filter { it.launched } + .onEach { reset(it) } + } + + override fun set(flag: Lab, value: Boolean) { + dataScope.launch(Dispatchers.IO) { + betaFlags.edit { prefs -> + prefs[flag.preferenceKey] = value + } + } + } + + override suspend fun get(flag: Lab): Boolean { + return betaFlags.data.map { prefs -> + if (flag.launched) return@map flag.default + prefs[flag.preferenceKey] ?: flag.default + }.firstOrNull() ?: flag.default + } + + override fun observe(flag: Lab): StateFlow = betaFlags.data.map { prefs -> + if (flag.launched) return@map flag.default + prefs[flag.preferenceKey] ?: flag.default + }.stateIn(dataScope, started = SharingStarted.Eagerly, flag.default) + + override fun observe(): StateFlow> = betaFlags.data.map { prefs -> + Lab.entries.filterNot { it.launched }.map { + val value = if (it.launched) { + it.default + } else { + prefs[it.preferenceKey] ?: it.default + } + + BetaFeature(it, value) + } + }.stateIn( + dataScope, + started = SharingStarted.Eagerly, + Lab.entries.map { BetaFeature(it, it.default) } + ) + + override fun reset(flag: Lab) { + dataScope.launch { + betaFlags.edit { it.remove(flag.preferenceKey) } + } + } + + override fun reset() { + dataScope.launch { + betaFlags.edit { it.clear() } + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/data/Account.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/data/Account.kt new file mode 100644 index 000000000..d7219da3b --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/data/Account.kt @@ -0,0 +1,7 @@ +package xyz.flipchat.app.data + + +data class Account( + val entropy: String, + val userIdBase58: String +) diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/data/RoomInfo.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/data/RoomInfo.kt new file mode 100644 index 000000000..fdb3805c8 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/data/RoomInfo.kt @@ -0,0 +1,33 @@ +package xyz.flipchat.app.data + +import androidx.compose.ui.graphics.Color +import com.getcode.model.ID +import com.getcode.model.Kin +import com.getcode.ui.utils.generateComplementaryColorPalette + +data class RoomInfo( + val id: ID? = null, + val number: Long = 0, + val title: String = "", + val imageUrl: String? = null, + val memberCount: Int = 0, + val hostId: ID? = null, + val hostName: String? = null, + val roomNumber: Long = 0, + val messagingFee: Kin = Kin.fromQuarks(0), +) { + val customTitle: String = runCatching { Regex("^#\\d+:\\s*(.*)").find(title)?.groupValues?.get(1).orEmpty() }.getOrDefault("") + + companion object { + val DEFAULT_GRADIENT_SAMPLE = Triple( + Color(0xFFFFBB00), + Color(0xFF7306B7), + Color(0xFF3E32C4), + ) + } + + val gradientColors: Triple + get() { + return id?.let { generateComplementaryColorPalette(it) } ?: DEFAULT_GRADIENT_SAMPLE + } +} diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/accesskey/BaseAccessKeyViewModel.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/accesskey/BaseAccessKeyViewModel.kt new file mode 100644 index 000000000..ccd66a499 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/accesskey/BaseAccessKeyViewModel.kt @@ -0,0 +1,291 @@ +package xyz.flipchat.app.features.accesskey + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Typeface +import android.os.Environment +import androidx.core.graphics.applyCanvas +import androidx.core.graphics.drawable.toBitmap +import androidx.lifecycle.viewModelScope +import com.getcode.libs.qr.QRCodeGenerator +import com.getcode.manager.TopBarManager +import com.getcode.network.repository.DeniedReason +import com.getcode.network.repository.ErrorSubmitIntent +import com.getcode.network.repository.ErrorSubmitIntentException +import com.getcode.services.manager.MnemonicManager +import com.getcode.theme.Alert +import com.getcode.theme.White +import com.getcode.ui.utils.toAGColor +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.decodeBase64 +import com.getcode.view.BaseViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.kin.sdk.base.tools.Base58 +import timber.log.Timber +import xyz.flipchat.app.R +import xyz.flipchat.app.theme.FC_Primary +import xyz.flipchat.app.util.media.MediaScanner +import xyz.flipchat.app.util.save +import xyz.flipchat.services.user.UserManager +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.roundToInt +import com.getcode.theme.R as themeR + + +data class AccessKeyUiModel( + val entropyB64: String? = null, + val isLoading: Boolean = false, + val isSuccess: Boolean = false, + val isEnabled: Boolean = true, + val words: List = listOf(), + val wordsFormatted: String = "", + val accessKeyBitmap: Bitmap? = null, + val accessKeyCroppedBitmap: Bitmap? = null, +) + +abstract class BaseAccessKeyViewModel( + private val resources: ResourceHelper, + private val mnemonicManager: MnemonicManager, + private val mediaScanner: MediaScanner, + userManager: UserManager, + private val qrCodeGenerator: QRCodeGenerator +) : BaseViewModel(resources) { + val uiFlow = MutableStateFlow(AccessKeyUiModel()) + + init { + userManager.state + .distinctUntilChangedBy { it.entropy } + .map { it.entropy } + .filterNotNull() + .take(1) + .onEach { initWithEntropy(it) } + .launchIn(viewModelScope) + } + + fun initWithEntropy(entropyB64: String) { + if (uiFlow.value.entropyB64 == entropyB64) return + Timber.d("entropy=$entropyB64") + val words = mnemonicManager.fromEntropyBase64(entropyB64).words + val wordsFormatted = getAccessKeyText(words).joinToString("\n") + + uiFlow.value = uiFlow.value.copy( + entropyB64 = entropyB64, + words = words, + wordsFormatted = wordsFormatted + ) + + CoroutineScope(Dispatchers.IO).launch { + val accessKeyBitmap = createBitmapForExport(words = words, entropyB64 = entropyB64) + val accessKeyBitmapDisplay = + createBitmapForExport(drawBackground = false, words, entropyB64) + val accessKeyCroppedBitmap = + Bitmap.createBitmap(accessKeyBitmapDisplay, 0, 500, 1200, 1450) + + uiFlow.value = uiFlow.value.copy( + accessKeyBitmap = accessKeyBitmap, + accessKeyCroppedBitmap = accessKeyCroppedBitmap + ) + } + } + + private fun getAccessKeyText(words: List): List { + return listOf( + words.subList(0, 6).joinToString(" "), + words.subList(6, 12).joinToString(" ") + ) + } + + private val targetWidth = 1200 + private val targetHeight = 2500 + + private val logoWidth = 92.4f + private val logoHeight = 132 + private val qrCodeSize = 360 + + private val bgTopOffset = 550 + private val logoTopOffset = 770 + private val qrTopOffset = 980 + private val keyTextTopOffset = 1600 + private val topTextTopOffset = 200 + private val bottomTextTopOffset = 2000 + + internal suspend fun saveBitmapToFile(): Result { + uiFlow.update { it.copy(isLoading = true) } + val bitmap = uiFlow.value.accessKeyBitmap + ?: return Result.failure(IllegalStateException("No access key?")) + val destination = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) + + return withContext(Dispatchers.IO) { + runCatching { + val result = bitmap.save( + destination = destination, + name = { + val date: DateFormat = SimpleDateFormat("yyy-MM-dd-h-mm", Locale.CANADA) + "Flipchat-Recovery-${date.format(Date())}.png" + } + ) + if (result) { + mediaScanner.scan(destination) + } + result + } + }.onFailure { + getAccessKeySaveError() + uiFlow.update { it.copy(isLoading = false, isSuccess = false) } + }.onSuccess { + uiFlow.update { it.copy(isLoading = false, isSuccess = true) } + } + } + + private fun createBitmapForExport( + drawBackground: Boolean = true, + words: List, + entropyB64: String + ): Bitmap { + val accessKeyText = getAccessKeyText(words) + + val accessKeyBg = resources.getDrawable(R.drawable.ic_access_key_bg) + ?.toBitmap(812, 1353)!! + + val imageLogo = + resources.getDrawable(R.drawable.ic_flipchat_logo_access_key) + ?.toBitmap(logoWidth.roundToInt(), logoHeight)!! + + val imageOut = Bitmap.createBitmap( + targetWidth, targetHeight, + Bitmap.Config.ARGB_8888 + ).applyCanvas { + val accessBgActualWidth = + accessKeyBg.getScaledWidth(resources.displayMetrics) + + if (drawBackground) { + val paintBackground = Paint() + paintBackground.color = FC_Primary.toAGColor() + paintBackground.style = Paint.Style.FILL + drawPaint(paintBackground) + } + + val topTextChunks = getString(R.string.subtitle_accessKeySnapshotWarning) + .split(" ", "\n") + .chunked(7) + .map { it.joinToString(" ") } + + topTextChunks.forEachIndexed { index, text -> + drawText( + canvas = this, + y = topTextTopOffset + (60 * (index + 1)), + sizePx = 40, + color = Alert.toAGColor(), + text = text + ) + } + + + drawBitmap( + accessKeyBg, + (((targetWidth - accessBgActualWidth) / 2)).toFloat(), + bgTopOffset.toFloat(), + null + ) + + drawBitmap( + imageLogo, + ((targetWidth - logoWidth) / 2).toFloat(), + logoTopOffset.toFloat(), + null + ) + + getQrCode(entropyB64)?.let { bitmap -> + drawBitmap( + bitmap, + ((targetWidth - qrCodeSize) / 2).toFloat(), + qrTopOffset.toFloat(), + null + ) + } + + drawText( + canvas = this, + y = keyTextTopOffset, + sizePx = 32, + color = White.toAGColor(), + text = accessKeyText[0] + ) + + drawText( + canvas = this, + y = keyTextTopOffset + 40, + sizePx = 32, + color = White.toAGColor(), + text = accessKeyText[1] + ) + + val bottomTextChunks = getString(R.string.subtitle_accessKeySnapshotDescription) + .split(" ") + .chunked(8) + .map { it.joinToString(" ") } + + bottomTextChunks.forEachIndexed { index, text -> + drawText( + canvas = this, + y = bottomTextTopOffset + (60 * (index + 1)), + sizePx = 40, + color = White.toAGColor(), + text = text + ) + } + + } + return imageOut + } + + private fun getQrCode(entropyB64: String): Bitmap? { + val base58 = Base58.encode(entropyB64.decodeBase64()) + val url = "${resources.getString(R.string.app_root_url)}/login?data=$base58" + + return qrCodeGenerator.generate(url, qrCodeSize) + } + + private fun drawText( + canvas: Canvas, + y: Int, + x: Int? = null, + sizePx: Int, + color: Int, + text: String + ) { + val textPaint = Paint(Paint.ANTI_ALIAS_FLAG) + textPaint.color = color + textPaint.textSize = sizePx.toFloat() + textPaint.typeface = Typeface.create( + resources.getFont(themeR.font.avenir_next_demi), + Typeface.BOLD + ) + + val bounds1 = android.graphics.Rect() + textPaint.getTextBounds(text, 0, text.length, bounds1) + val xV: Int = x ?: ((targetWidth - bounds1.width()) / 2) + canvas.drawText(text, xV.toFloat(), y.toFloat(), textPaint) + } + + private fun getAccessKeySaveError() = TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToSave), + resources.getString(R.string.error_description_failedToSave), + ) +} diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/balance/BalanceScreen.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/balance/BalanceScreen.kt new file mode 100644 index 000000000..8f98f1fbe --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/balance/BalanceScreen.kt @@ -0,0 +1,341 @@ +package xyz.flipchat.app.features.balance + +import android.os.Parcelable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material.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.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import xyz.flipchat.app.R +import com.getcode.model.Currency +import com.getcode.model.CurrencyCode +import com.getcode.model.Rate +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.extensions.getActivityScopedViewModel +import com.getcode.theme.CodeTheme +import com.getcode.theme.DesignSystem +import com.getcode.ui.components.text.AmountArea +import com.getcode.ui.theme.CodeCircularProgressIndicator +import com.getcode.utils.Kin +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import xyz.flipchat.services.user.AuthState + +@Parcelize +class BalanceScreen : Screen, Parcelable { + + @IgnoredOnParcel + override val key: ScreenKey = uniqueScreenKey + + @Composable + override fun Content() { + val viewModel = getActivityScopedViewModel() + val state by viewModel.stateFlow.collectAsState() + BalanceScreenContent(state, viewModel::dispatchEvent) + } + +} + +@Composable +fun BalanceScreenContent( + state: BalanceSheetViewModel.State, + dispatch: (BalanceSheetViewModel.Event) -> Unit, +) { + val navigator = LocalCodeNavigator.current + + BalanceContent( + state = state, + dispatch = dispatch, + faqOpen = { }, +// openChat = { }, + buyMoreKin = { } + ) +} + +@Composable +fun BalanceContent( + state: BalanceSheetViewModel.State, + dispatch: (BalanceSheetViewModel.Event) -> Unit, + faqOpen: () -> Unit, +// openChat: (Chat) -> Unit, + buyMoreKin: () -> Unit, +) { + val lazyListState = rememberLazyListState() + val navigator = LocalCodeNavigator.current + + val chatsEmpty = false +// val chatsEmpty by remember(state.chats) { +// derivedStateOf { state.chats.isEmpty() } +// } + + val canClickBalance = false + + LazyColumn( + modifier = Modifier + .fillMaxWidth(), + state = lazyListState + ) { + item { + Column( + modifier = Modifier + .fillParentMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset,) + .padding(top = CodeTheme.dimens.inset) + ) { + BalanceTop( + state, + canClickBalance, + ) + } + } + +// item { +// Column( +// modifier = Modifier +// .fillParentMaxWidth() +// .padding(horizontal = CodeTheme.dimens.inset) +// ) { +// if (!chatsEmpty && !state.chatsLoading && !state.isKinSelected) { +// KinValueHint(faqOpen) +// } +// } +// } + +// itemsIndexed( +// state.chats, +// key = { _, item -> item.id }, +// contentType = { _, item -> item } +// ) { index, chat -> +// ChatNode(chat = chat, onClick = { openChat(chat) }) +// Divider( +// modifier = Modifier.padding(start = CodeTheme.dimens.inset), +// color = White10, +// ) +// } + + when { + state.chatsLoading -> { + item { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + CodeTheme.dimens.grid.x2, + CenterVertically + ), + ) { + CodeCircularProgressIndicator() + Text( + modifier = Modifier.fillMaxWidth(0.6f), + text = stringResource(R.string.subtitle_loadingBalanceAndTransactions), + textAlign = TextAlign.Center + ) + } + } + } + +// chatsEmpty -> { +// item { +// EmptyTransactionsHint(faqOpen) +// } +// } + } + } +} + +@Composable +fun BalanceTop( + state: BalanceSheetViewModel.State, + isClickable: Boolean, + onClick: () -> Unit = {} +) { + if (state.amountText.isEmpty() && state.authState is AuthState.LoggedIn) { + + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + CodeCircularProgressIndicator() + } + } else { + AmountArea( + amountText = state.amountText, + isAltCaption = false, + isAltCaptionKinIcon = false, + isLoading = state.chatsLoading, + currencyResId = state.currencyFlag, + isClickable = false, + onClick = onClick, + textStyle = CodeTheme.typography.displayLarge, + ) + } +} + +@Composable +private fun ColumnScope.KinValueHint(onClick: () -> Unit) { + val context = LocalContext.current + Row( + modifier = Modifier + .align(CenterHorizontally) + ) { + val annotatedBalanceString = buildAnnotatedString { + val infoString = stringResource(R.string.subtitle_valueKinChanges) + val actionString = stringResource(R.string.subtitle_learnMore) + val textString = "$infoString $actionString" + + val startIndex = textString.indexOf(actionString) + val endIndex = textString.length + append(textString) + + addStyle( + style = SpanStyle( + textDecoration = TextDecoration.Underline + ), start = startIndex, end = endIndex + ) + addStyle( + style = SpanStyle(color = CodeTheme.colors.textSecondary), + start = 0, + end = textString.length + ) + addStringAnnotation( + tag = stringResource(R.string.subtitle_learnMore), + annotation = "", + start = startIndex, + end = endIndex + ) + } + + ClickableText( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.grid.x1), + text = annotatedBalanceString, + style = CodeTheme.typography.textMedium, + onClick = { + annotatedBalanceString + .getStringAnnotations( + context.getString(R.string.subtitle_learnMore), + it, + it + ) + .firstOrNull()?.let { onClick() } + } + ) + } +} + +@Composable +private fun EmptyTransactionsHint(faqOpen: () -> Unit) { + val context = LocalContext.current + Column( + modifier = Modifier + .height(200.dp) + .padding(horizontal = CodeTheme.dimens.grid.x6), + verticalArrangement = Arrangement.Bottom, + ) { + Row( + modifier = Modifier + .align(CenterHorizontally) + ) { + Text( + modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x1), + text = stringResource(R.string.subtitle_dontHaveKin), + color = CodeTheme.colors.textSecondary, + style = CodeTheme.typography.textMedium + ) + } + + val annotatedLinkString: AnnotatedString = buildAnnotatedString { + val linkString = "Check out the FAQ" + val remainderString = " to find out how to get some." + val textString = linkString + remainderString + + val startIndex = textString.indexOf(linkString) + val endIndex = linkString.length + + append(textString) + addStyle( + style = SpanStyle( + color = CodeTheme.colors.textSecondary, + ), start = 0, end = textString.length + ) + addStyle( + style = SpanStyle( + textDecoration = TextDecoration.Underline + ), start = startIndex, end = endIndex + ) + + addStringAnnotation( + tag = context.getString(R.string.title_faq), + annotation = "", + start = startIndex, + end = endIndex + ) + } + + Row( + modifier = Modifier + .align(CenterHorizontally) + ) { + ClickableText( + text = annotatedLinkString, + style = CodeTheme.typography.textMedium.copy(textAlign = TextAlign.Center), + onClick = { + annotatedLinkString + .getStringAnnotations( + context.getString(R.string.title_faq), + it, + it + ) + .firstOrNull()?.let { _ -> faqOpen() } + } + ) + } + } +} + + +@Preview +@Composable +private fun TopPreview() { + DesignSystem { + val model = BalanceSheetViewModel.State( + amountText = "$12.34 of Kin", + marketValue = 2_225_100.0, + selectedRate = Rate(Currency.Kin.rate, CurrencyCode.KIN), + chatsLoading = false, + currencyFlag = R.drawable.ic_currency_kin, +// chats = emptyList(), + isBucketDebuggerEnabled = false, + isBucketDebuggerVisible = false, + ) + + BalanceTop( + state = model, + isClickable = true + ) + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/balance/BalanceSheetViewModel.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/balance/BalanceSheetViewModel.kt new file mode 100644 index 000000000..45fe88b32 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/balance/BalanceSheetViewModel.kt @@ -0,0 +1,119 @@ +package xyz.flipchat.app.features.balance + +import androidx.lifecycle.viewModelScope +import com.getcode.model.Currency +import com.getcode.model.Rate +import com.getcode.network.BalanceController +import com.getcode.utils.Kin +import com.getcode.utils.network.NetworkConnectivityListener +import com.getcode.view.BaseViewModel2 +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import xyz.flipchat.services.user.AuthState +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject + +@HiltViewModel +class BalanceSheetViewModel @Inject constructor( + userManager: UserManager, + balanceController: BalanceController, + networkObserver: NetworkConnectivityListener, +) : BaseViewModel2( + initialState = State(), + updateStateForEvent = updateStateForEvent +) { + data class State( + val authState: AuthState = AuthState.Unknown, + val amountText: String = "", + val marketValue: Double = 0.0, + val selectedRate: Rate? = null, + val isKinSelected: Boolean = false, + val currencyFlag: Int? = null, + val chatsLoading: Boolean = false, + val isBucketDebuggerEnabled: Boolean = false, + val isBucketDebuggerVisible: Boolean = false, + ) + + sealed interface Event { + data class OnAuthStateChanged(val authState: AuthState): Event + data class OnDebugBucketsEnabled(val enabled: Boolean) : Event + data class OnDebugBucketsVisible(val show: Boolean) : Event + data class OnLatestRateChanged(val rate: Rate) : Event + + data class OnBalanceChanged( + val flagResId: Int?, + val marketValue: Double, + val display: String, + val isKin: Boolean, + ) : Event + + data class OnChatsLoading(val loading: Boolean) : Event +// data class OnChatsUpdated(val chats: List) : Event + data object OnOpened: Event + } + + init { + userManager.state + .map { it.authState } + .onEach { dispatchEvent(Event.OnAuthStateChanged(it)) } + .launchIn(viewModelScope) + + balanceController.formattedBalance + .filterNotNull() + .distinctUntilChanged() + .onEach { + dispatchEvent( + Dispatchers.Main, + Event.OnBalanceChanged( + flagResId = it.currency?.resId, + marketValue = it.marketValue, + display = it.formattedValue, + isKin = it.currency == Currency.Kin + ) + ) + } + .launchIn(viewModelScope) + } + + companion object { + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + is Event.OnDebugBucketsEnabled -> { state -> + state.copy(isBucketDebuggerEnabled = event.enabled) + } + + is Event.OnDebugBucketsVisible -> { state -> + state.copy(isBucketDebuggerVisible = event.show) + } + + is Event.OnLatestRateChanged -> { state -> + state.copy(selectedRate = event.rate) + } + + is Event.OnBalanceChanged -> { state -> + state.copy( + currencyFlag = event.flagResId, + marketValue = event.marketValue, + amountText = event.display, + isKinSelected = event.isKin + ) + } + is Event.OnChatsLoading -> { state -> + state.copy(chatsLoading = event.loading) + } +// is Event.OnChatsUpdated -> { state -> +// state.copy(chats = event.chats) +// } + + Event.OnOpened -> { state -> state } + is Event.OnAuthStateChanged -> { state -> state.copy(authState = event.authState) } + } + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/beta/BetaFlagsScreen.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/beta/BetaFlagsScreen.kt new file mode 100644 index 000000000..2c597d800 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/beta/BetaFlagsScreen.kt @@ -0,0 +1,122 @@ +package xyz.flipchat.app.features.beta + +import android.os.Parcelable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.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.res.stringResource +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.SettingsSwitchRow +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import xyz.flipchat.app.R +import xyz.flipchat.app.beta.Lab +import xyz.flipchat.app.ui.LocalLabs + +@Parcelize +class BetaFlagsScreen : Screen, Parcelable { + + @IgnoredOnParcel + override val key: ScreenKey = uniqueScreenKey + + @Composable + override fun Content() { + val navigator = LocalCodeNavigator.current + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = stringResource(R.string.title_betaFlags), + backButton = true, + onBackIconClicked = navigator::pop + ) + BetaFlagsScreenContent() + } + } +} + +@Composable +private fun BetaFlagsScreenContent() { + val betaFlagsController = LocalLabs.current + val betaFlags by betaFlagsController.observe().collectAsState() + + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(betaFlags) { feature -> + SettingsSwitchRow( + title = feature.flag.title, + subtitle = feature.flag.message, + checked = feature.enabled + ) { + betaFlagsController.set(feature.flag, !feature.enabled) + } + } + if (betaFlags.isEmpty()) { + item { + Box(modifier = Modifier.fillParentMaxSize()) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "\uD83D\uDE2D", + style = CodeTheme.typography.displayMedium + ) + Text( + text = "Nothing Cooking in the Lab Right Now", + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain + ) + + Text( + text = "Check back in the next app update.", + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary + ) + + } + } + } + } + } +} + +private val Lab.title: String + get() = when (this) { + Lab.FollowerMode -> "Follower Mode" + Lab.ReplyToMessage -> "Swipe To Reply" + Lab.StartChatAtUnread -> "Open Conversation @ Last Unread" + Lab.RoomNameChanges -> "Room Name Changes For Hosts" + Lab.DeleteMessage -> "Delete Message Support" + Lab.OpenCloseRoom -> "Open/Close Rooms" + Lab.Tipping -> "Tipping" + Lab.LinkImages -> "Show Previews for Links" + } + +private val Lab.message: String + get() = when (this) { + Lab.FollowerMode -> "When enabled, you will gain the ability to watch rooms without joining first" + Lab.ReplyToMessage -> "When enabled, you will gain the ability to swipe to reply to messages in chat" + Lab.StartChatAtUnread -> "When enabled, conversations will resume at the last message you read" + Lab.RoomNameChanges -> "When enabled, hosts will gain the ability to set a desired name for their room" + Lab.DeleteMessage -> "When enabled, hosts will gain the ability to delete messages" + Lab.OpenCloseRoom -> "When enabled, hosts will gain the ability to temporarily close (and reopen) their rooms" + Lab.Tipping -> "When enabled, you'll gain the ability to double tap messages to tip the author" + Lab.LinkImages -> "When enabled, links shared in chat will show a preview image for the link" + } \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/ChatDirectiveBottomModal.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/ChatDirectiveBottomModal.kt new file mode 100644 index 000000000..16d62ee84 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/ChatDirectiveBottomModal.kt @@ -0,0 +1,46 @@ +package xyz.flipchat.app.features.chat + +import cafe.adriel.voyager.core.registry.ScreenRegistry +import com.getcode.manager.BottomBarManager +import com.getcode.model.Currency +import com.getcode.model.Kin +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.core.CodeNavigator +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.Kin +import com.getcode.utils.formatAmountString +import xyz.flipchat.app.R +import xyz.flipchat.app.features.chat.list.ChatListViewModel + + +fun openChatDirectiveBottomModal( + resources: ResourceHelper, + createCost: Kin, + viewModel: ChatListViewModel, + navigator: CodeNavigator, +) { + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + positiveText = resources.getString(R.string.action_enterRoomNumber), + negativeText = resources.getString( + R.string.action_createNewRoomWithCost, + formatAmountString( + resources = resources, + currency = Currency.Kin, + amount = createCost.quarks.toDouble(), + suffix = resources.getKinSuffix() + ) + ), + negativeStyle = BottomBarManager.BottomBarButtonStyle.Filled, + tertiaryText = resources.getString(R.string.action_cancel), + onPositive = { + navigator.push(ScreenRegistry.get(NavScreenProvider.Room.Lookup.Entry)) + }, + onNegative = { + viewModel.dispatchEvent(ChatListViewModel.Event.CreateRoomSelected) + }, + type = BottomBarManager.BottomBarMessageType.THEMED, + showScrim = true, + ) + ) +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/ChattableState.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/ChattableState.kt new file mode 100644 index 000000000..872fc9b1f --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/ChattableState.kt @@ -0,0 +1,15 @@ +package xyz.flipchat.app.features.chat.conversation + +import com.getcode.model.Kin + + +sealed interface ChattableState { + interface Active + data object DisabledByMute: ChattableState, Active + data class Spectator(val messageFee: Kin): ChattableState + data object TemporarilyEnabled: ChattableState + data object Enabled: ChattableState + data object DisabledByClosedRoom: ChattableState + + fun isActiveMember() = this is Active +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/ConversationChatInput.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/ConversationChatInput.kt new file mode 100644 index 000000000..069072f78 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/ConversationChatInput.kt @@ -0,0 +1,227 @@ +package xyz.flipchat.app.features.chat.conversation + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.getcode.model.Currency +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.chat.ChatInput +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.utils.addIf +import com.getcode.ui.utils.keyboardAsState +import com.getcode.ui.utils.withTopBorder +import com.getcode.util.resources.LocalResources +import com.getcode.utils.Kin +import com.getcode.utils.formatAmountString +import xyz.flipchat.app.R + +@Composable +fun ConversationChatInput( + state: ConversationViewModel.State, + focusRequester: FocusRequester, + dispatchEvent: (ConversationViewModel.Event) -> Unit, +) { + var previousState by remember { mutableStateOf(null) } + val ime = LocalSoftwareKeyboardController.current + val keyboardVisible by keyboardAsState() + + AnimatedContent( + targetState = state.chattableState, + transitionSpec = { + if (previousState == null) { + // Skip animation when coming from null + EnterTransition.None.togetherWith(ExitTransition.None) + } else { + (slideInVertically { it }).togetherWith(ExitTransition.None) + } + }, + label = "chat input area" + ) { chattableState -> + when (chattableState) { + null -> { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(100.dp) + ) + } + + ChattableState.DisabledByMute -> { + Text( + modifier = Modifier + .fillMaxWidth() + .background(CodeTheme.colors.secondary) + .padding( + top = CodeTheme.dimens.grid.x1, + bottom = CodeTheme.dimens.grid.x3 + ) + .navigationBarsPadding(), + textAlign = TextAlign.Center, + text = stringResource(R.string.title_youHaveBeenMuted), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary + ) + } + + is ChattableState.TemporarilyEnabled, + is ChattableState.Enabled -> { + Column { + if (state.isHost && !keyboardVisible && state.isOpenCloseEnabled && !state.isRoomOpen) { + RoomOpenControlBar( + modifier = Modifier.fillMaxWidth(), + ) { dispatchEvent(ConversationViewModel.Event.OnOpenStateChangedRequested) } + } + + ChatInput( + modifier = Modifier.navigationBarsPadding(), + state = state.textFieldState, + sendCashEnabled = false, + focusRequester = focusRequester, + onSendMessage = { + if (chattableState is ChattableState.TemporarilyEnabled) { + ime?.hide() + } + dispatchEvent(ConversationViewModel.Event.OnSendMessage) + }, + onSendCash = { dispatchEvent(ConversationViewModel.Event.OnSendCash) } + ) + + LaunchedEffect(chattableState) { + if (chattableState is ChattableState.TemporarilyEnabled) { + focusRequester.requestFocus() + } + } + } + } + + is ChattableState.Spectator -> { + Column( + modifier = Modifier + .addIf( + !state.isRoomOpen && state.isOpenCloseEnabled + ) { + Modifier.background(CodeTheme.colors.secondary) + } + .navigationBarsPadding(), + ) { + if (!state.isRoomOpen && state.isOpenCloseEnabled) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding( + top = CodeTheme.dimens.grid.x1, + bottom = CodeTheme.dimens.grid.x2 + ), + textAlign = TextAlign.Center, + text = stringResource(R.string.title_roomIsClosed), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary + ) + } + + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding( + start = CodeTheme.dimens.inset, + end = CodeTheme.dimens.inset + ), + buttonState = ButtonState.Filled, + text = stringResource( + R.string.action_sendMessageInRoom, + formatAmountString( + resources = LocalResources.current!!, + currency = Currency.Kin, + amount = chattableState.messageFee.quarks.toDouble(), + suffix = stringResource(R.string.core_kin) + ) + ), + ) { + dispatchEvent(ConversationViewModel.Event.OnSendMessageForFee) + } + } + } + + ChattableState.DisabledByClosedRoom -> { + Text( + modifier = Modifier + .fillMaxWidth() + .background(CodeTheme.colors.secondary) + .padding( + top = CodeTheme.dimens.grid.x1, + bottom = CodeTheme.dimens.grid.x3 + ) + .navigationBarsPadding(), + textAlign = TextAlign.Center, + text = stringResource(R.string.title_roomIsClosed), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary + ) + } + } + } + + // Update the previous state + LaunchedEffect(state.chattableState) { + previousState = state.chattableState + } +} + +@Composable +private fun RoomOpenControlBar( + modifier: Modifier = Modifier, + onChangeRequest: () -> Unit, +) { + Row( + modifier = modifier + .withTopBorder(color = CodeTheme.colors.dividerVariant) + .padding(CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.subtitle_roomIsClosed), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain + ) + + CodeButton( + text = stringResource(R.string.action_reopen), + shape = CircleShape, + buttonState = ButtonState.Filled, + overrideContentPadding = true, + contentPadding = PaddingValues(horizontal = CodeTheme.dimens.grid.x2), + style = CodeTheme.typography.textSmall + ) { + onChangeRequest() + } + } +} diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/ConversationMessages.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/ConversationMessages.kt new file mode 100644 index 000000000..af5517af0 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/ConversationMessages.kt @@ -0,0 +1,250 @@ +package xyz.flipchat.app.features.chat.conversation + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Surface +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalUriHandler +import androidx.paging.compose.LazyPagingItems +import com.getcode.manager.TopBarManager +import com.getcode.model.chat.AnnouncementAction +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.screens.ContextSheet +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.chat.MessageList +import com.getcode.ui.components.chat.MessageListEvent +import com.getcode.ui.components.chat.MessageListPointerResult +import com.getcode.ui.components.chat.messagecontents.LocalAnnouncementActionResolver +import com.getcode.ui.components.chat.messagecontents.ResolvedAction +import com.getcode.ui.components.chat.utils.ChatItem +import com.getcode.ui.components.chat.utils.HandleMessageChanges +import com.getcode.ui.components.text.markup.Markup +import com.getcode.ui.utils.animateScrollToItemWithFullVisibility +import com.getcode.ui.utils.keyboardAsState +import com.getcode.ui.utils.scrollToItemWithFullVisibility +import com.getcode.ui.utils.verticalScrollStateGradient +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import xyz.flipchat.app.R +import xyz.flipchat.app.util.dialNumber +import kotlin.math.abs + +@OptIn(ExperimentalMaterialApi::class) +@Composable +internal fun ConversationMessages( + modifier: Modifier = Modifier, + state: ConversationViewModel.State, + messages: LazyPagingItems, + focusRequester: FocusRequester, + dispatchEvent: (ConversationViewModel.Event) -> Unit, +) { + val navigator = LocalCodeNavigator.current + val lazyListState = rememberLazyListState() + val keyboardVisible by keyboardAsState() + val ime = LocalSoftwareKeyboardController.current + val composeScope = rememberCoroutineScope() + val uriHandler = LocalUriHandler.current + val context = LocalContext.current + + fun resolveAnnouncementAction(action: AnnouncementAction): ResolvedAction? { + return when (action) { + AnnouncementAction.Unknown -> null + AnnouncementAction.Share -> ResolvedAction( + text = if (state.isHost) { + context.getString(R.string.action_shareRoomLinkAsHost) + } else { + context.getString(R.string.action_shareRoomLinkAsMember) + }, + onClick = { dispatchEvent(ConversationViewModel.Event.OnShareRoomLink) } + ) + } + } + + Box( + modifier = modifier, + ) { + CompositionLocalProvider(LocalAnnouncementActionResolver provides { resolveAnnouncementAction(it) }) { + MessageList( + modifier = Modifier + .fillMaxSize() + .verticalScrollStateGradient( + scrollState = lazyListState, + color = CodeTheme.colors.background, + showAtStartAlways = true, + showAtEnd = false + ), + messages = messages, + listState = lazyListState, + handleMessagePointers = { (current, previous, next) -> + MessageListPointerResult( + current.sender.id == previous?.sender?.id, + current.sender.id == next?.sender?.id + ) + }, + dispatch = { event -> + when (event) { + is MessageListEvent.AdvancePointer -> { + dispatchEvent(ConversationViewModel.Event.MarkRead(event.messageId)) + } + + is MessageListEvent.OpenMessageActions -> { + composeScope.launch { + if (keyboardVisible) { + ime?.hide() + delay(500) + } + navigator.show(ContextSheet(event.actions)) + } + } + + is MessageListEvent.OnMarkupEvent -> { + when (val markup = event.markup) { + is Markup.RoomNumber -> { + dispatchEvent(ConversationViewModel.Event.LookupRoom(markup.number)) + } + + is Markup.Url -> { + runCatching { + uriHandler.openUri(markup.link) + }.onFailure { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + title = context.getString(R.string.error_title_failedToOpenLink), + message = context.getString(R.string.error_description_failedToOpenLink) + ) + ) + } + } + + is Markup.Phone -> { + context.dialNumber(markup.phoneNumber) + } + } + } + + is MessageListEvent.ReplyToMessage -> { + dispatchEvent(ConversationViewModel.Event.ReplyTo(event.message)) + focusRequester.requestFocus() + } + + is MessageListEvent.ViewOriginalMessage -> { + composeScope.launch { + val itemIndex = messages.itemSnapshotList + .filterIsInstance() + .indexOfFirst { it.chatMessageId == event.originalMessageId } + + val currentItemIndex = messages.itemSnapshotList + .filterIsInstance() + .indexOfFirst { it.chatMessageId == event.messageId } + + if (itemIndex >= 0) { + val distance = abs(itemIndex - currentItemIndex) + + println("distance from current ($currentItemIndex) is $distance") + if (distance <= 100) { + // Animate smoothly if within 100 items + lazyListState.animateScrollToItemWithFullVisibility( + to = itemIndex, + ) + } else { + // Jump directly if too far + lazyListState.scrollToItemWithFullVisibility( + to = itemIndex, + ) + } + } + } + } + + is MessageListEvent.TipMessage -> { + composeScope.launch { + if (keyboardVisible) { + ime?.hide() + delay(500) + } + dispatchEvent( + ConversationViewModel.Event.TipUser( + event.message.chatMessageId, + event.message.sender.id.orEmpty() + ) + ) + } + } + + is MessageListEvent.ShowTipsForMessage -> { + composeScope.launch { + if (keyboardVisible) { + ime?.hide() + delay(500) + } + navigator.show(MessageTipsSheet(event.tips)) + } + } + + is MessageListEvent.UnreadStateHandled -> { + dispatchEvent(ConversationViewModel.Event.OnUnreadStateHandled) + } + } + } + ) + } + + val animatedAlpha by animateFloatAsState( + targetValue = if (lazyListState.canScrollBackward) 1f else 0f, + animationSpec = tween(durationMillis = 300), + label = "alpha of jump-to-bottom" + ) + + Surface( + modifier = Modifier + .graphicsLayer { + alpha = animatedAlpha + } + .padding( + end = CodeTheme.dimens.inset, + bottom = CodeTheme.dimens.inset + ).align(Alignment.BottomEnd), + shape = CircleShape, + color = CodeTheme.colors.tertiary, + onClick = { + composeScope.launch { + if (lazyListState.firstVisibleItemIndex > 100) { + lazyListState.scrollToItem(0) + } else { + lazyListState.animateScrollToItem(0) + } + } + } + ) { + Image( + modifier = Modifier.padding(CodeTheme.dimens.grid.x2), + imageVector = Icons.Outlined.KeyboardArrowDown, + contentDescription = null, + colorFilter = ColorFilter.tint(CodeTheme.colors.onSurface) + ) + } + } + + HandleMessageChanges(listState = lazyListState, items = messages) { message -> + dispatchEvent(ConversationViewModel.Event.MarkDelivered(message.chatMessageId)) + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/ConversationScreen.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/ConversationScreen.kt new file mode 100644 index 000000000..27d732d5c --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/ConversationScreen.kt @@ -0,0 +1,341 @@ +package xyz.flipchat.app.features.chat.conversation + +import android.os.Parcelable +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Clear +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 +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.lifecycle.Lifecycle +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.hilt.getViewModel +import com.getcode.model.ID +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.screens.AppScreen +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.OnLifecycleEvent +import com.getcode.ui.components.chat.TypingIndicator +import com.getcode.ui.components.chat.messagecontents.MessageReplyPreview +import com.getcode.ui.components.chat.utils.ChatItem +import com.getcode.ui.components.chat.utils.ReplyMessageAnchor +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.utils.addIf +import com.getcode.ui.utils.keyboardAsState +import com.getcode.ui.utils.noRippleClickable +import com.getcode.ui.utils.unboundedClickable +import com.getcode.ui.utils.withTopBorder +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import xyz.flipchat.app.features.home.TabbedHomeScreen + +@Parcelize +data class ConversationScreen( + val chatId: ID? = null, + val roomNumber: Long? = null +) : AppScreen(), Parcelable { + @IgnoredOnParcel + override val key: ScreenKey = uniqueScreenKey + + @Composable + override fun Content() { + val navigator = LocalCodeNavigator.current + val vm = getViewModel() + + val keyboardVisible by keyboardAsState() + val keyboard = LocalSoftwareKeyboardController.current + val composeScope = rememberCoroutineScope() + + LaunchedEffect(chatId) { + if (chatId != null) { + vm.dispatchEvent( + ConversationViewModel.Event.OnChatIdChanged(chatId) + ) + } + } + + LaunchedEffect(roomNumber) { + if (roomNumber != null) { + vm.dispatchEvent( + ConversationViewModel.Event.OnRoomNumberChanged(roomNumber) + ) + } + } + + LaunchedEffect(vm) { + vm.eventFlow + .filterIsInstance() + .onEach { + navigator.show(ScreenRegistry.get(NavScreenProvider.CreateAccount.Start)) + }.launchIn(this) + } + + val context = LocalContext.current + LaunchedEffect(vm) { + vm.eventFlow + .filterIsInstance() + .onEach { + if (it.show) { + Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show() + } + if (it.fatal) { + navigator.popAll() + } + }.launchIn(this) + } + + LaunchedEffect(vm) { + vm.eventFlow + .filterIsInstance() + .map { it.roomId } + .onEach { + navigator.push(ScreenRegistry.get(NavScreenProvider.Room.Messages(it))) + }.launchIn(this) + } + + LaunchedEffect(vm) { + vm.eventFlow + .filterIsInstance() + .map { it.roomInfoArgs } + .onEach { + navigator.push( + ScreenRegistry.get( + NavScreenProvider.Room.Preview(args = it, returnToSender = true) + ) + ) + }.launchIn(this) + } + + LaunchedEffect(vm) { + vm.eventFlow + .filterIsInstance() + .onEach { + context.startActivity(it.intent) + }.launchIn(this) + } + + LaunchedEffect(result) { + result + .filter { it == true } + .onEach { vm.dispatchEvent(ConversationViewModel.Event.OnAccountCreated) } + .launchIn(this) + } + + val state by vm.stateFlow.collectAsState() + + val goBack = { + composeScope.launch { + if (keyboardVisible) { + keyboard?.hide() + delay(500) + } + navigator.popUntil { it is TabbedHomeScreen } + } + } + + BackHandler { goBack() } + + OnLifecycleEvent { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + vm.dispatchEvent(ConversationViewModel.Event.Resumed) + } + + Lifecycle.Event.ON_STOP, + Lifecycle.Event.ON_DESTROY -> { + vm.dispatchEvent(ConversationViewModel.Event.Stopped) + } + + else -> Unit + } + } + + val openRoomDetails = { + composeScope.launch { + if (keyboardVisible) { + keyboard?.hide() + delay(500) + } + navigator.push( + ScreenRegistry.get( + NavScreenProvider.Room.Info( + state.roomInfoArgs + ) + ) + ) + } + } + + Column { + AppBarWithTitle( + title = { + ConversationTitle( + modifier = Modifier + .noRippleClickable { openRoomDetails() }, + state = state + ) + }, + leftIcon = { AppBarDefaults.UpNavigation { goBack() } }, + rightContents = { + AppBarDefaults.Overflow { openRoomDetails() } + } + ) + + val messages = vm.messages.collectAsLazyPagingItems() + + ConversationScreenContent( + state = state, + messages = messages, + dispatchEvent = vm::dispatchEvent + ) + } + } +} + +@Composable +private fun ConversationScreenContent( + state: ConversationViewModel.State, + messages: LazyPagingItems, + dispatchEvent: (ConversationViewModel.Event) -> Unit, +) { + val navigator = LocalCodeNavigator.current + val focusRequester = remember { FocusRequester() } + + CodeScaffold( + bottomBar = { + Column( + modifier = Modifier + .addIf(navigator.lastItem is ConversationScreen) { + Modifier.imePadding() + }, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + AnimatedVisibility( + visible = state.showTypingIndicator, + enter = slideInVertically( + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ) + ) { it } + scaleIn() + fadeIn(), + exit = fadeOut() + scaleOut() + slideOutVertically { it } + ) { + TypingIndicator( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.grid.x2) + ) + } + + Column( + modifier = Modifier + .addIf(state.chattableState?.isActiveMember() == true) { + Modifier.withTopBorder(color = CodeTheme.colors.dividerVariant) + } + ) { + AnimatedContent( + targetState = state.replyMessage, + transitionSpec = { + (slideInVertically { it }).togetherWith(slideOutVertically { it }) + }, + label = "replying to message visibility", + ) { replyingTo -> + if (replyingTo != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(CodeTheme.colors.background) + .height(IntrinsicSize.Min) + ) { + MessageReplyPreview( + modifier = Modifier.weight(1f), + originalMessage = ReplyMessageAnchor( + id = replyingTo.id, + sender = replyingTo.sender, + message = replyingTo.message, + isDeleted = false, + deletedBy = null, + ) + ) + Image( + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(vertical = CodeTheme.dimens.grid.x1) + .padding(end = CodeTheme.dimens.grid.x1) + .unboundedClickable { + dispatchEvent(ConversationViewModel.Event.CancelReply) + }, + imageVector = Icons.Outlined.Clear, + colorFilter = ColorFilter.tint(Color.White), + contentDescription = null + ) + } + } + } + + ConversationChatInput( + state = state, + focusRequester = focusRequester, + dispatchEvent = dispatchEvent + ) + } + } + } + ) { padding -> + ConversationMessages( + modifier = Modifier + .fillMaxSize() + .padding(padding), + state = state, + messages = messages, + focusRequester = focusRequester, + dispatchEvent = dispatchEvent + ) + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/ConversationTitle.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/ConversationTitle.kt new file mode 100644 index 000000000..449e05925 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/ConversationTitle.kt @@ -0,0 +1,96 @@ +package xyz.flipchat.app.features.chat.conversation + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.chat.UserAvatar +import xyz.flipchat.app.R + +@Composable +internal fun ConversationTitle( + modifier: Modifier = Modifier, + state: ConversationViewModel.State, +) { + val listenerCount = remember(state.members) { + state.listeners + } + + ConversationTitle( + modifier = modifier, + imageUri = state.imageUri ?: state.conversationId, + title = state.title, + listenerCount = listenerCount, + ) +} + +@Composable +internal fun ConversationTitle( + modifier: Modifier = Modifier, + imageUri: Any?, + title: String, + listenerCount: Int?, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2) + ) { + UserAvatar( + modifier = Modifier + .padding(start = CodeTheme.dimens.grid.x2) + .size(CodeTheme.dimens.staticGrid.x6) + .clip(CircleShape), + data = imageUri, + overlay = { + Image( + modifier = Modifier.padding(5.dp), + painter = painterResource(R.drawable.ic_fc_chats), + colorFilter = ColorFilter.tint(Color.White), + contentDescription = null, + ) + } + ) + Column { + Text( + modifier = Modifier.fillMaxWidth(), + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = CodeTheme.typography.screenTitle.copy(fontSize = 18.sp) + ) + + Text( + text = if (listenerCount != null) { + pluralStringResource( + R.plurals.title_roomInfoListenerCount, + listenerCount, + listenerCount + ) + } else { + "" + }, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/ConversationViewModel.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/ConversationViewModel.kt new file mode 100644 index 000000000..e8320b28b --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/ConversationViewModel.kt @@ -0,0 +1,1588 @@ +package xyz.flipchat.app.features.chat.conversation + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Intent +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.insertSeparators +import androidx.paging.map +import com.getcode.manager.BottomBarManager +import com.getcode.manager.TopBarManager +import com.getcode.model.ID +import com.getcode.model.Kin +import com.getcode.model.KinAmount +import com.getcode.model.chat.Deleter +import com.getcode.model.chat.MessageContent +import com.getcode.model.chat.MessageStatus +import com.getcode.model.chat.Sender +import com.getcode.model.uuid +import com.getcode.navigation.RoomInfoArgs +import com.getcode.services.model.ExtendedMetadata +import com.getcode.ui.components.chat.messagecontents.MessageControlAction +import com.getcode.ui.components.chat.messagecontents.MessageControls +import com.getcode.ui.components.chat.utils.ChatItem +import com.getcode.ui.components.chat.utils.MessageTip +import com.getcode.ui.components.chat.utils.ReplyMessageAnchor +import com.getcode.ui.components.chat.utils.localizedText +import com.getcode.util.resources.ResourceHelper +import com.getcode.util.toInstantFromMillis +import com.getcode.utils.CurrencyUtils +import com.getcode.utils.ErrorUtils +import com.getcode.utils.SuppressibleException +import com.getcode.utils.TraceType +import com.getcode.utils.base58 +import com.getcode.utils.network.retryable +import com.getcode.utils.timestamp +import com.getcode.utils.trace +import com.getcode.view.BaseViewModel2 +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import kotlinx.datetime.Instant +import timber.log.Timber +import xyz.flipchat.app.R +import xyz.flipchat.app.beta.Lab +import xyz.flipchat.app.beta.Labs +import xyz.flipchat.app.features.login.register.onError +import xyz.flipchat.app.features.login.register.onResult +import xyz.flipchat.app.util.IntentUtils +import xyz.flipchat.chat.RoomController +import xyz.flipchat.controllers.ChatsController +import xyz.flipchat.controllers.ProfileController +import xyz.flipchat.services.PaymentController +import xyz.flipchat.services.PaymentEvent +import xyz.flipchat.services.data.metadata.JoinChatPaymentMetadata +import xyz.flipchat.services.data.metadata.SendMessageAsListenerPaymentMetadata +import xyz.flipchat.services.data.metadata.SendTipMessagePaymentMetadata +import xyz.flipchat.services.data.metadata.erased +import xyz.flipchat.services.data.metadata.typeUrl +import xyz.flipchat.services.domain.model.chat.ConversationMember +import xyz.flipchat.services.domain.model.chat.ConversationMessage +import xyz.flipchat.services.domain.model.chat.ConversationWithMembersAndLastPointers +import xyz.flipchat.services.extensions.titleOrFallback +import xyz.flipchat.services.internal.data.mapper.nullIfEmpty +import xyz.flipchat.services.user.AuthState +import xyz.flipchat.services.user.UserManager +import java.util.UUID +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +@HiltViewModel +class ConversationViewModel @Inject constructor( + private val userManager: UserManager, + private val roomController: RoomController, + private val chatsController: ChatsController, + private val paymentController: PaymentController, + private val profileController: ProfileController, + private val resources: ResourceHelper, + private val currencyUtils: CurrencyUtils, + clipboardManager: ClipboardManager, + private val betaFeatures: Labs, +) : BaseViewModel2( + initialState = State.Default, + updateStateForEvent = updateStateForEvent +) { + + private var typingJob: Job? = null + + data class State( + val selfId: ID?, + val selfName: String?, + val hostId: ID?, + val conversationId: ID?, + val unreadCount: Int?, + val chattableState: ChattableState?, + val textFieldState: TextFieldState, + val replyEnabled: Boolean, + val replyMessage: MessageReplyAnchor?, + val startAtUnread: Boolean, + val unreadStateHandled: Boolean, + val title: String, + val imageUri: String?, + val lastSeen: Instant?, + val members: Int?, + val listeners: Int?, + val pointers: Map, + val pointerRefs: Map, + val showTypingIndicator: Boolean, + val isSelfTyping: Boolean, + val isRoomOpen: Boolean, + val isOpenCloseEnabled: Boolean, + val isTippingEnabled: Boolean, + val isLinkImagePreviewsEnabled: Boolean, + val roomInfoArgs: RoomInfoArgs, + val lastReadMessage: UUID?, + ) { + val isHost: Boolean + get() = selfId != null && hostId != null && selfId == hostId + + companion object { + val Default = State( + selfId = null, + selfName = null, + hostId = null, + imageUri = null, + conversationId = null, + unreadCount = null, + unreadStateHandled = false, + chattableState = null, + lastReadMessage = null, + textFieldState = TextFieldState(), + replyEnabled = false, + startAtUnread = false, + replyMessage = null, + title = "", + lastSeen = null, + pointers = emptyMap(), + pointerRefs = emptyMap(), + members = null, + listeners = null, + showTypingIndicator = false, + isSelfTyping = false, + isRoomOpen = true, + isOpenCloseEnabled = false, + isTippingEnabled = false, + isLinkImagePreviewsEnabled = false, + roomInfoArgs = RoomInfoArgs(), + ) + } + } + + sealed interface Event { + data class OnSelfChanged(val id: ID?, val displayName: String?) : Event + data class OnChatIdChanged(val chatId: ID?) : Event + data class OnRoomNumberChanged(val roomNumber: Long) : Event + data class OnConversationChanged(val conversationWithPointers: ConversationWithMembersAndLastPointers) : + Event + + data class OnInitialUnreadCountDetermined(val count: Int) : Event + data object OnUnreadStateHandled : Event + data class OnTitlesChanged(val title: String, val roomCardTitle: String) : Event + data class OnUserActivity(val activity: Instant) : Event + data object OnSendCash : Event + data object OnSendMessage : Event + data class SendMessage(val paymentId: ID? = null) : Event + data object SendMessageWithFee : Event + data object RevealIdentity : Event + + data class OnAbilityToChatChanged(val state: ChattableState) : Event + data class OnPointersUpdated(val pointers: Map) : Event + data class MarkRead(val messageId: ID) : Event + data class MarkDelivered(val messageId: ID) : Event + + data class OnReplyEnabled(val enabled: Boolean) : Event + data class OnOpenCloseEnabled(val enabled: Boolean) : Event + data class OnTippingEnabled(val enabled: Boolean) : Event + data class OnLinkImagePreviewsEnabled(val enabled: Boolean) : Event + data class ReplyTo(val anchor: MessageReplyAnchor) : Event { + constructor(chatItem: ChatItem.Message) : this( + MessageReplyAnchor( + chatItem.chatMessageId, + chatItem.sender, + chatItem.message + ) + ) + } + + data object CancelReply : Event + + data class OnStartAtUnread(val enabled: Boolean) : Event + + data object OnJoinRequestedFromSpectating : Event + data object OnSendMessageForFee : Event + data object NeedsAccountCreated : Event + data object OnAccountCreated : Event + data object OnJoinRoom : Event + + data object Resumed : Event + data object Stopped : Event + + data object OnTypingStarted : Event + data object OnTypingStopped : Event + + data object OnOpenStateChangedRequested : Event + data class OnOpenRoom(val conversationId: ID) : Event + data class OnCloseRoom(val conversationId: ID) : Event + + data class CopyMessage(val text: String) : Event + data class DeleteMessage(val conversationId: ID, val messageId: ID) : Event + data class RemoveUser(val conversationId: ID, val userId: ID) : Event + data class ReportUser(val userId: ID, val messageId: ID) : Event + data class MuteUser(val conversationId: ID, val userId: ID) : Event + data class BlockUser(val userId: ID) : Event + data class UnblockUser(val userId: ID) : Event + data class TipUser(val messageId: ID, val userId: ID) : Event + data class PromoteUser(val conversationId: ID, val userId: ID) : Event + data class DemoteUser(val conversationId: ID, val userId: ID) : Event + + data object OnShareRoomLink : Event + data class ShareRoom(val intent: Intent) : Event + + data object OnUserTypingStarted : Event + data object OnUserTypingStopped : Event + + data class LookupRoom(val number: Long) : Event + data class OpenRoomPreview(val roomInfoArgs: RoomInfoArgs) : Event + data class OpenRoom(val roomId: ID) : Event + + data class Error( + val fatal: Boolean, + val message: String = "", + val show: Boolean = true + ) : Event + } + + init { + userManager.state + .map { it.userId to it.displayName } + .distinctUntilChanged() + .onEach { (id, displayName) -> + dispatchEvent(Event.OnSelfChanged(id, displayName)) + }.launchIn(viewModelScope) + + betaFeatures.observe(Lab.ReplyToMessage) + .onEach { dispatchEvent(Event.OnReplyEnabled(it)) } + .launchIn(viewModelScope) + + betaFeatures.observe(Lab.OpenCloseRoom) + .onEach { dispatchEvent(Event.OnOpenCloseEnabled(it)) } + .launchIn(viewModelScope) + + betaFeatures.observe(Lab.StartChatAtUnread) + .onEach { dispatchEvent(Event.OnStartAtUnread(it)) } + .launchIn(viewModelScope) + + betaFeatures.observe(Lab.Tipping) + .onEach { dispatchEvent(Event.OnTippingEnabled(it)) } + .launchIn(viewModelScope) + + betaFeatures.observe(Lab.LinkImages) + .onEach { dispatchEvent(Event.OnLinkImagePreviewsEnabled(it)) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.chatId } + .filterNotNull() + .map { roomController.getUnreadCount(it) } + .onEach { dispatchEvent(Event.OnInitialUnreadCountDetermined(it)) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.chatId } + .filterNotNull() + .mapNotNull { + retryable( + maxRetries = 5, + delayDuration = 3.seconds, + ) { roomController.getConversation(it) } + }.onEach { dispatchEvent(Event.OnConversationChanged(it)) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.chatId } + .filterNotNull() + .onEach { + runCatching { + roomController.openMessageStream(viewModelScope, it) + }.onFailure { + it.printStackTrace() + ErrorUtils.handleError(it) + } + }.launchIn(viewModelScope) + + stateFlow + .mapNotNull { it.conversationId } + .distinctUntilChanged() + .flatMapLatest { + roomController.observeConversation(it) + }.filterNotNull() + .distinctUntilChanged() + .onEach { + val (conversation, members, _) = it + val selfMember = members.firstOrNull { userManager.isSelf(it.id) } + val chattableState = if (selfMember != null) { + val isMuted = selfMember.isMuted + val isSpectator = !selfMember.isFullMember + val isRoomClosedAsMember = !conversation.isOpen && !selfMember.isHost + + when { + isRoomClosedAsMember -> ChattableState.DisabledByClosedRoom + // remain temp enabled + stateFlow.value.chattableState is ChattableState.TemporarilyEnabled -> ChattableState.TemporarilyEnabled + isSpectator -> ChattableState.Spectator( + Kin.fromQuarks(it.conversation.messagingFee ?: 0) + ) + + isMuted -> ChattableState.DisabledByMute + else -> ChattableState.Enabled + } + } else { + ChattableState.Enabled + } + + if (stateFlow.value.chattableState != chattableState) { + dispatchEvent(Event.OnAbilityToChatChanged(chattableState)) + } + + dispatchEvent(Event.OnConversationChanged(it)) + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.conversationWithPointers.conversation } + .distinctUntilChanged() + .map { + val title = it.titleOrFallback(resources) + val roomCardTitle = it.titleOrFallback(resources) + title to roomCardTitle + }.distinctUntilChanged() + .onEach { dispatchEvent(Event.OnTitlesChanged(it.first, it.second)) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { delay(300) } + .map { it.messageId } + .filter { stateFlow.value.conversationId != null } + .map { it to stateFlow.value.conversationId!! } + .onEach { (messageId, conversationId) -> + roomController.advancePointer( + conversationId, + messageId, + MessageStatus.Read + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { delay(300) } + .map { it.messageId } + .filter { stateFlow.value.conversationId != null } + .map { it to stateFlow.value.conversationId!! } + .onEach { (messageId, conversationId) -> + roomController.advancePointer( + conversationId, + messageId, + MessageStatus.Delivered + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + if (stateFlow.value.chattableState is ChattableState.Enabled) { + dispatchEvent(Event.SendMessage()) + } else { + dispatchEvent(Event.SendMessageWithFee) + } + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { stateFlow.value.roomInfoArgs } + .filter { it.ownerId != null } + .map { profileController.getPaymentDestinationForUser(it.ownerId!!) } + .mapNotNull { + if (it.isSuccess) { + val paymentDestination = it.getOrNull() ?: return@mapNotNull null + val sendMessageMetadata = SendMessageAsListenerPaymentMetadata( + userId = userManager.userId!!, + chatId = stateFlow.value.conversationId!! + ) + + val metadata = ExtendedMetadata.Any( + data = sendMessageMetadata.erased(), + typeUrl = sendMessageMetadata.typeUrl + ) + + val amount = + KinAmount.fromQuarks(stateFlow.value.roomInfoArgs.messagingFeeQuarks) + + paymentController.presentPublicPaymentConfirmation( + amount = amount, + destination = paymentDestination, + metadata = metadata, + ) + } else { + return@mapNotNull null + } + }.flatMapLatest { + paymentController.eventFlow.take(1) + }.onEach { event -> + when (event) { + PaymentEvent.OnPaymentCancelled -> Unit + is PaymentEvent.OnPaymentError -> Unit + + is PaymentEvent.OnPaymentSuccess -> { + event.acknowledge(true) { + dispatchEvent(Event.SendMessage(event.intentId)) + } + } + } + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { + val paymentId = it.paymentId + val state = stateFlow.value + val textFieldState = state.textFieldState + val text = textFieldState.text.toString().trim() + if (text.isEmpty()) return@mapNotNull null + + textFieldState.clearText() + + val replyingTo = state.replyMessage + + dispatchEvent(Event.CancelReply) + + if (replyingTo != null) { + roomController.sendReply( + state.conversationId!!, + replyingTo.id, + text + ) + } else { + roomController.sendMessage(state.conversationId!!, text, paymentId) + } + }.onResult( + onError = { + trace( + tag = "Conversation", + message = "message failed to send", + type = TraceType.Error, + error = it + ) + }, + onSuccess = { + // if we are temporarily allowed to speak, reset back to spectator + if (stateFlow.value.chattableState is ChattableState.TemporarilyEnabled) { + val fee = Kin.fromQuarks(stateFlow.value.roomInfoArgs.messagingFeeQuarks) + dispatchEvent(Event.OnAbilityToChatChanged(ChattableState.Spectator(fee))) + } + + trace( + tag = "Conversation", + message = "message sent successfully", + type = TraceType.Silent + ) + } + ) + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { stateFlow.value.conversationId } + .distinctUntilChanged() + .onEach { + runCatching { + roomController.openMessageStream(viewModelScope, it) + }.onFailure { + ErrorUtils.handleError(it) + } + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { delay(400) } + .onEach { dispatchEvent(Event.OnJoinRoom) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + roomController.closeMessageStream() + userManager.roomClosed() + }.launchIn(viewModelScope) + + stateFlow + .map { it.conversationId } + .filterNotNull() + .distinctUntilChanged() + .flatMapLatest { + roomController.observeTyping(it) + } + .onEach { isOtherUserTyping -> + if (isOtherUserTyping) { + dispatchEvent(Event.OnTypingStarted) + } else { + dispatchEvent(Event.OnTypingStopped) + } + }.launchIn(viewModelScope) + + stateFlow + .map { it.textFieldState } + .flatMapLatest { ts -> snapshotFlow { ts.text } } + .distinctUntilChanged() + .onEach { text -> + typingJob?.cancel() + + if (text.isEmpty()) { + dispatchEvent(Event.OnUserTypingStopped) + } else { + if (!stateFlow.value.isSelfTyping) { + dispatchEvent(Event.OnUserTypingStarted) + } + + typingJob = viewModelScope.launch { + delay(1.seconds) + dispatchEvent(Event.OnUserTypingStopped) + } + } + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { stateFlow.value.conversationId } + .onEach { roomController.onUserStartedTypingIn(it) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { stateFlow.value.conversationId } + .onEach { roomController.onUserStoppedTypingIn(it) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { stateFlow.value.conversationId } + .map { it to stateFlow.value.isRoomOpen } + .onEach { (conversationId, isOpen) -> + confirmOpenStateChange(conversationId, isOpen) + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.conversationId } + .map { roomController.enableChat(it) } + .onResult( + onError = { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToReopenRoom), + resources.getString(R.string.error_description_failedToReopenRoom) + ) + ) + }, + ).launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.conversationId } + .map { roomController.disableChat(it) } + .onResult( + onError = { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToCloseRoom), + resources.getString(R.string.error_description_failedToCloseRoom) + ) + ) + }, + ).launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { (conversationId, messageId) -> + roomController.deleteMessage(conversationId, messageId) + }.onError { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + title = resources.getString(R.string.error_title_failedToDeleteMessage), + message = resources.getString(R.string.error_description_failedToDeleteMessage) + ) + ) + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { (conversationId, userId) -> + roomController.removeUser(conversationId, userId) + }.onError { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + title = resources.getString(R.string.error_title_failedToRemoveUser), + message = resources.getString(R.string.error_description_failedToRemoveUser) + ) + ) + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { (userId, messageId) -> + roomController.reportUserForMessage(userId, messageId) + }.onResult( + onError = { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + title = resources.getString(R.string.error_title_failedToReportUserForMessage), + message = resources.getString(R.string.error_description_failedToReportUserForMessage) + ) + ) + }, + onSuccess = { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + title = resources.getString(R.string.success_title_reportUser), + message = resources.getString(R.string.success_description_reportUser), + type = TopBarManager.TopBarMessageType.SUCCESS + ) + ) + } + ) + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { (chatId, userId) -> + roomController.muteUser(chatId, userId) + }.onResult( + onError = { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + title = resources.getString(R.string.error_title_failedToMuteUser), + message = resources.getString(R.string.error_description_failedToMuteUser) + ) + ) + } + ) + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.userId } + .map { roomController.blockUser(it) } + .onResult( + onError = { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + title = resources.getString(R.string.error_title_failedToBlockUser), + message = resources.getString(R.string.error_description_failedToBlockUser) + ) + ) + } + ) + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.userId } + .map { roomController.unblockUser(it) } + .onResult( + onError = { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + title = resources.getString(R.string.error_title_failedToUnblockUser), + message = resources.getString(R.string.error_description_failedToUnblockUser) + ) + ) + } + ) + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.text } + .onEach { + clipboardManager.setPrimaryClip(ClipData.newPlainText("", it)) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { data -> + val result = profileController.getPaymentDestinationForUser(data.userId) + if (result.isSuccess) { + result.getOrNull()?.let { Result.success(it to data.messageId) } + ?: Result.failure(Throwable()) + } else { + Result.failure(result.exceptionOrNull() ?: Throwable()) + } + } + .mapNotNull { + if (it.isSuccess) { + val (paymentDestination, messageId) = it.getOrNull() ?: return@mapNotNull null + val tipPaymentMetadata = SendTipMessagePaymentMetadata( + tipperId = userManager.userId!!, + chatId = stateFlow.value.conversationId!!, + messageId = messageId + ) + + val metadata = ExtendedMetadata.Any( + data = tipPaymentMetadata.erased(), + typeUrl = tipPaymentMetadata.typeUrl + ) + + paymentController.presentMessageTipConfirmation( + destination = paymentDestination, + metadata = metadata + ) + } else { + ErrorUtils.handleError(it.exceptionOrNull() ?: SuppressibleException("Failed retrieving destination address")) + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToSendTip), + resources.getString( + R.string.error_description_failedToSendTip, + ) + ) + ) + return@mapNotNull null + } + }.flatMapLatest { + paymentController.eventFlow.take(1) + } + .onEach { event -> + when (event) { + PaymentEvent.OnPaymentCancelled -> Unit + is PaymentEvent.OnPaymentError -> Unit + + is PaymentEvent.OnPaymentSuccess -> { + val metadata = (event.metadata as ExtendedMetadata.Any).data.let { + SendTipMessagePaymentMetadata.unerase(it) + } + + roomController.sendTip( + conversationId = metadata.chatId, + messageId = metadata.messageId, + amount = event.amount, + paymentIntentId = event.intentId + ).onFailure { + event.acknowledge(false) { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToSendTip), + resources.getString( + R.string.error_description_failedToSendTip, + ) + ) + ) + } + }.onSuccess { + event.acknowledge(true) { + + } + } + } + } + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.number } + .map { roomNumber -> + chatsController.lookupRoom(roomNumber) + .onFailure { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToGetRoom), + resources.getString( + R.string.error_description_failedToGetRoom, + roomNumber + ) + ) + ) + }.onSuccess { (room, members) -> + val moderator = members.firstOrNull { it.isModerator } + val isMember = members.any { it.isSelf } + if (isMember) { + dispatchEvent(Event.OpenRoom(room.id)) + } else { + val roomInfo = RoomInfoArgs( + roomId = room.id, + roomNumber = room.roomNumber, + roomTitle = room.titleOrFallback(resources), + memberCount = members.count(), + ownerId = room.ownerId, + hostName = moderator?.identity?.displayName, + messagingFeeQuarks = room.messagingFee.quarks, + ) + dispatchEvent(Event.OpenRoomPreview(roomInfo)) + } + } + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { userManager.authState } + .onEach { + if (it is AuthState.LoggedIn) { + dispatchEvent(Event.OnJoinRoom) + } else { + dispatchEvent(Event.NeedsAccountCreated) + } + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .filter { stateFlow.value.chattableState is ChattableState.Spectator } + .map { stateFlow.value.roomInfoArgs } + .filter { it.ownerId != null } + .map { profileController.getPaymentDestinationForUser(it.ownerId!!) } + .mapNotNull { + if (it.isSuccess) { + val paymentDestination = it.getOrNull() ?: return@mapNotNull null + val joinChatMetadata = JoinChatPaymentMetadata( + userId = userManager.userId!!, + chatId = stateFlow.value.conversationId!! + ) + + val metadata = ExtendedMetadata.Any( + data = joinChatMetadata.erased(), + typeUrl = joinChatMetadata.typeUrl + ) + + val amount = + KinAmount.fromQuarks(stateFlow.value.roomInfoArgs.messagingFeeQuarks) + + paymentController.presentPublicPaymentConfirmation( + amount = amount, + destination = paymentDestination, + metadata = metadata, + ) + } else { + return@mapNotNull null + } + }.flatMapLatest { + paymentController.eventFlow.take(1) + }.onEach { event -> + when (event) { + PaymentEvent.OnPaymentCancelled -> Unit + is PaymentEvent.OnPaymentError -> Unit + + is PaymentEvent.OnPaymentSuccess -> { + val roomId = stateFlow.value.conversationId.orEmpty() + chatsController.joinRoomAsFullMember(roomId, event.intentId) + .onFailure { + event.acknowledge(false) { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToJoinRoom), + resources.getString( + R.string.error_description_failedToJoinRoom, + stateFlow.value.roomInfoArgs.roomTitle.orEmpty() + ) + ) + ) + } + } + .onSuccess { + event.acknowledge(true) { + + } + } + } + } + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { IntentUtils.shareRoom(stateFlow.value.roomInfoArgs.roomNumber) } + .onEach { dispatchEvent(Event.ShareRoom(it)) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { roomController.promoteUser(it.conversationId, it.userId) } + .onResult( + onError = { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToPromoteUser), + resources.getString(R.string.error_description_failedToPromoteUser) + ) + ) + } + ) + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { roomController.demoteUser(it.conversationId, it.userId) } + .onResult( + onError = { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToDemoteUser), + resources.getString(R.string.error_description_failedToDemoteUser) + ) + ) + } + ).launchIn(viewModelScope) + } + + @OptIn(ExperimentalCoroutinesApi::class) + val messages: Flow> = stateFlow + .map { it.conversationId } + .filterNotNull() + .distinctUntilChanged() + .flatMapLatest { roomController.messages(it).flow } + .distinctUntilChanged() + .map { page -> + val currentState = stateFlow.value // Cache state upfront + val pointerRefs = currentState.pointerRefs // cache expensive pointer ref map upfront + val enableReply = + currentState.replyEnabled && currentState.chattableState is ChattableState.Enabled + + val enableLinkImages = currentState.isLinkImagePreviewsEnabled + + page.map { indice -> + val (_, message, member, contents, reply, tipInfo) = indice + + println("member=$member") + val status = findClosestMessageStatus( + timestamp = message.id.uuid?.timestamp, + statusMap = pointerRefs, + fallback = if (contents.isFromSelf) MessageStatus.Sent else MessageStatus.Unknown + ) + + val anchor = if (reply != null) { + ReplyMessageAnchor( + id = reply.message.id, + message = reply.contentEntity, + isDeleted = reply.message.isDeleted, + deletedBy = reply.message.deletedBy?.let { id -> + Deleter( + id = id, + isSelf = userManager.isSelf(id), + isHost = currentState.hostId == message.deletedBy + ) + }, + sender = Sender( + id = reply.message.senderId, + profileImage = reply.member?.imageUri.takeIf { + it.orEmpty().isNotEmpty() + }, + isFullMember = reply.member?.isFullMember == true, + displayName = reply.member?.memberName ?: "Deleted", + isSelf = reply.contentEntity.isFromSelf, + isBlocked = reply.member?.isBlocked == true, + isHost = reply.message.senderId == currentState.hostId && !contents.isFromSelf, + ) + ) + } else { + null + } + + val tippingEnabled = + currentState.isTippingEnabled && !userManager.isSelf(message.senderId) + + val tips = if (currentState.isTippingEnabled && tipInfo.isNotEmpty()) { + tipInfo.map { (tip, member) -> + MessageTip( + amount = tip.kin, + tipper = Sender( + id = member?.id, + profileImage = member?.imageUri.nullIfEmpty(), + displayName = member?.memberName, + isHost = member?.isHost ?: false, + isSelf = userManager.isSelf(member?.id), + isBlocked = member?.isBlocked ?: false, + ) + ) + } + } else { + emptyList() + } + + ChatItem.Message( + chatMessageId = message.id, + message = contents, + date = message.dateMillis.toInstantFromMillis(), + status = status, + isDeleted = message.isDeleted, + deletedBy = if (message.isDeleted) { + Deleter( + id = message.deletedBy, + isSelf = userManager.isSelf(message.deletedBy), + isHost = currentState.hostId == message.deletedBy + ) + } else { + null + }, + wasSentAsFullMember = !message.sentOffStage, + enableMarkup = true, + enableReply = enableReply && !message.isDeleted, + showTimestamp = false, + enableTipping = tippingEnabled, + enableLinkImagePreview = enableLinkImages, + sender = Sender( + id = message.senderId, + profileImage = member?.imageUri.takeIf { it.orEmpty().isNotEmpty() }, + displayName = member?.memberName ?: "Deleted", + isSelf = contents.isFromSelf, + isFullMember = member?.isFullMember == true, + isHost = message.senderId == currentState.hostId, + isBlocked = member?.isBlocked == true, + ), + originalMessage = anchor, + messageControls = MessageControls( + actions = buildMessageActions( + message, + member, + contents, + enableReply, + enableTip = tippingEnabled + ), + ), + tips = tips + ) + } + } + .flowOn(Dispatchers.Default) + .mapLatest { data -> + var unreadSeparatorInserted = false + + data.insertSeparators { before: ChatItem.Message?, after: ChatItem.Message? -> + val separators = mutableListOf() + + if ( + stateFlow.value.startAtUnread && + !unreadSeparatorInserted && + after?.chatMessageId?.uuid == stateFlow.value.lastReadMessage && + before?.sender?.isSelf == false + ) { + unreadSeparatorInserted = true + val unreadCount = stateFlow.value.unreadCount ?: return@insertSeparators null + separators.add(ChatItem.UnreadSeparator(unreadCount)) + } + + val beforeDate = before?.relativeDate + val afterDate = after?.relativeDate + + // if the date changes between two items, add a date separator + if (beforeDate != afterDate) { + beforeDate?.let { separators.add(ChatItem.Date(before.date)) } + } + + if (separators.isNotEmpty()) { + ChatItem.Separators(separators) + } else { + null + } + } + }.cachedIn(viewModelScope) + + private fun buildMessageActions( + message: ConversationMessage, + member: ConversationMember?, + contents: MessageContent, + enableReply: Boolean, + enableTip: Boolean, + ): List { + return mutableListOf().apply { + if (stateFlow.value.isHost) { + if (member?.memberName?.isNotEmpty() == true && !contents.isFromSelf) { + if (member.isFullMember) { + add( + MessageControlAction.DemoteUser { + confirmUserDemote( + conversationId = message.conversationId, + user = member.memberName, + userId = message.senderId + ) + } + ) + } else { + add( + MessageControlAction.PromoteUser { + confirmUserPromote( + conversationId = message.conversationId, + user = member.memberName, + userId = message.senderId + ) + } + ) + } + } + } + + if (enableReply) { + add( + MessageControlAction.Reply { + val sender = Sender( + id = message.senderId, + profileImage = member?.imageUri.takeIf { it.orEmpty().isNotEmpty() }, + displayName = member?.memberName ?: "Deleted", + isSelf = contents.isFromSelf, + isHost = message.senderId == stateFlow.value.hostId && !contents.isFromSelf, + isBlocked = member?.isBlocked == true + ) + val anchor = MessageReplyAnchor(message.id, sender, contents) + dispatchEvent(Event.ReplyTo(anchor)) + } + ) + } + + if (enableTip) { + add( + MessageControlAction.Tip { + dispatchEvent(Event.TipUser(message.id, message.senderId)) + } + ) + } + + add( + MessageControlAction.Copy { + dispatchEvent( + Event.CopyMessage( + contents.localizedText( + resources = resources, + currencyUtils = currencyUtils + ) + ) + ) + } + ) + } + buildSelfDefenseControls(message, member, contents) + } + + private fun buildSelfDefenseControls( + message: ConversationMessage, + member: ConversationMember?, + contents: MessageContent + ): List { + return mutableListOf().apply { + // delete message + if (stateFlow.value.isHost || contents.isFromSelf) { + add( + MessageControlAction.Delete { + confirmMessageDelete( + conversationId = message.conversationId, + messageId = message.id + ) + } + ) + } + + + if (stateFlow.value.isHost) { + if (member?.memberName?.isNotEmpty() == true && !contents.isFromSelf) { +// add( +// MessageControlAction.RemoveUser(member.memberName.orEmpty()) { +// confirmUserRemoval( +// conversationId = message.conversationId, +// user = member.memberName, +// userId = message.senderId, +// ) +// } +// ) + add( + MessageControlAction.MuteUser { + confirmUserMute( + conversationId = message.conversationId, + user = member.memberName, + userId = message.senderId, + ) + } + ) + } + } + + if (!contents.isFromSelf) { + if (member?.isBlocked != null) { + if (member.isBlocked) { + add( + MessageControlAction.UnblockUser { + dispatchEvent(Event.UnblockUser(member.id)) + } + ) + } else { + add( + MessageControlAction.BlockUser { + confirmUserBlock( + user = member.memberName, + userId = message.senderId, + ) + } + ) + } + } + + add( + MessageControlAction.ReportUserForMessage(member?.memberName.orEmpty()) { + confirmUserReport( + user = member?.memberName, + userId = message.senderId, + messageId = message.id + ) + } + ) + } + }.toList() + } + + private fun confirmMessageDelete(conversationId: ID, messageId: ID) { + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = resources.getString(R.string.title_deleteMessage), + subtitle = resources.getString(R.string.subtitle_deleteMessage), + positiveText = resources.getString(R.string.action_delete), + tertiaryText = resources.getString(R.string.action_cancel), + onPositive = { dispatchEvent(Event.DeleteMessage(conversationId, messageId)) }, + type = BottomBarManager.BottomBarMessageType.DESTRUCTIVE, + showScrim = true, + ) + ) + } + + private fun confirmUserRemoval(conversationId: ID, user: String?, userId: ID) { + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = resources.getString(R.string.title_removeUserFromRoom, user.orEmpty()), + subtitle = resources.getString(R.string.subtitle_removeUserFromRoom), + positiveText = resources.getString(R.string.action_remove), + negativeText = "", + tertiaryText = resources.getString(R.string.action_cancel), + onPositive = { dispatchEvent(Event.RemoveUser(conversationId, userId)) }, + onNegative = { }, + type = BottomBarManager.BottomBarMessageType.DESTRUCTIVE, + showScrim = true, + ) + ) + } + + private fun confirmUserMute(conversationId: ID, user: String?, userId: ID) { + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = resources.getString( + R.string.title_muteUserInRoom, + user.orEmpty().ifEmpty { "User" }), + subtitle = resources.getString(R.string.subtitle_muteUserInRoom), + positiveText = resources.getString(R.string.action_mute), + negativeText = "", + tertiaryText = resources.getString(R.string.action_cancel), + onPositive = { dispatchEvent(Event.MuteUser(conversationId, userId)) }, + onNegative = { }, + type = BottomBarManager.BottomBarMessageType.DESTRUCTIVE, + showScrim = true, + ) + ) + } + + private fun confirmUserPromote(conversationId: ID, user: String?, userId: ID) { + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = resources.getString( + R.string.title_promoteUserInRoom, + user.orEmpty().ifEmpty { "User" }), + subtitle = resources.getString(R.string.subtitle_promoteUserInRoom), + positiveText = resources.getString(R.string.action_promote), + negativeText = "", + tertiaryText = resources.getString(R.string.action_cancel), + onPositive = { dispatchEvent(Event.PromoteUser(conversationId, userId)) }, + onNegative = { }, + type = BottomBarManager.BottomBarMessageType.THEMED, + showScrim = true, + ) + ) + } + + private fun confirmUserDemote(conversationId: ID, user: String?, userId: ID) { + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = resources.getString( + R.string.title_demoteUserInRoom, + user.orEmpty().ifEmpty { "User" }), + subtitle = resources.getString(R.string.subtitle_demoteUserInRoom), + positiveText = resources.getString(R.string.action_demote), + negativeText = "", + tertiaryText = resources.getString(R.string.action_cancel), + onPositive = { dispatchEvent(Event.DemoteUser(conversationId, userId)) }, + onNegative = { }, + type = BottomBarManager.BottomBarMessageType.DESTRUCTIVE, + showScrim = true, + ) + ) + } + + private fun confirmUserBlock(user: String?, userId: ID) { + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = resources.getString( + R.string.title_blockUserInRoom, + user.orEmpty().ifEmpty { "User" }, + ), + subtitle = resources.getString(R.string.subtitle_blockUserInRoom, user.orEmpty()), + positiveText = resources.getString(R.string.action_blockUser, user.orEmpty()), + negativeText = "", + tertiaryText = resources.getString(R.string.action_cancel), + onPositive = { dispatchEvent(Event.BlockUser(userId)) }, + onNegative = { }, + type = BottomBarManager.BottomBarMessageType.DESTRUCTIVE, + showScrim = true, + ) + ) + } + + private fun confirmUserReport(user: String?, userId: ID, messageId: ID) { + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = resources.getString(R.string.title_reportUserForMessage, user ?: "User"), + subtitle = resources.getString(R.string.subtitle_reportUserForMessage), + positiveText = resources.getString(R.string.action_report), + negativeText = "", + tertiaryText = resources.getString(R.string.action_cancel), + onPositive = { dispatchEvent(Event.ReportUser(userId, messageId)) }, + onNegative = { }, + type = BottomBarManager.BottomBarMessageType.DESTRUCTIVE, + showScrim = true, + ) + ) + } + + private fun confirmOpenStateChange(conversationId: ID, isRoomOpen: Boolean) { + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = if (isRoomOpen) resources.getString(R.string.prompt_title_closeRoom) else resources.getString( + R.string.prompt_title_reopenRoom + ), + subtitle = if (isRoomOpen) resources.getString(R.string.prompt_description_closeRoom) else resources.getString( + R.string.prompt_description_reopenRoom + ), + positiveText = if (isRoomOpen) resources.getString(R.string.action_closeTemporarily) else resources.getString( + R.string.action_reopenRoom + ), + tertiaryText = resources.getString(R.string.action_cancel), + onPositive = { + if (isRoomOpen) { + dispatchEvent(Event.OnCloseRoom(conversationId)) + } else { + dispatchEvent(Event.OnOpenRoom(conversationId)) + } + }, + type = BottomBarManager.BottomBarMessageType.THEMED, + showScrim = true, + ) + ) + } + + override fun onCleared() { + super.onCleared() + roomController.closeMessageStream() + viewModelScope.launch { + stateFlow.value.conversationId?.let { + roomController.onUserStoppedTypingIn(it) + } + } + } + + internal companion object { + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + is Event.OnChatIdChanged -> Timber.d("onChatID changed ${event.chatId?.base58}") + is Event.OnSelfChanged -> Timber.d("onSelf changed ${event.id?.base58}") + is Event.OnConversationChanged -> { + val members = event.conversationWithPointers.members.count() + Timber.d( + "conversation changed={id:${event.conversationWithPointers.conversation.id.base58}, " + + "unreadCount:${event.conversationWithPointers.conversation.unreadCount}, " + + "members:$members, " + + "pointers:${event.conversationWithPointers.pointers.count()}" + ) + } + + is Event.DeleteMessage -> { + Timber.d("Delete Message => ${event.messageId.uuid.toString()}") + } + + else -> Timber.d("event=${event}") + } + + when (event) { + is Event.OnChatIdChanged -> { state -> + state.copy( + conversationId = event.chatId, + textFieldState = TextFieldState() + ) + } + + is Event.OnInitialUnreadCountDetermined -> { state -> state.copy(unreadCount = event.count) } + + is Event.OnTitlesChanged -> { state -> + state.copy( + title = event.title, + roomInfoArgs = state.roomInfoArgs.copy( + roomTitle = event.roomCardTitle + ) + ) + } + + is Event.OnConversationChanged -> { state -> + val (conversation, _, _) = event.conversationWithPointers + val members = event.conversationWithPointers.members + val host = members.firstOrNull { it.isHost } + + state.copy( + conversationId = conversation.id, + imageUri = conversation.imageUri.orEmpty().takeIf { it.isNotEmpty() }, + pointers = event.conversationWithPointers.pointers, + lastReadMessage = state.lastReadMessage + ?: findLastReadMessage(event.conversationWithPointers.pointers), + pointerRefs = event.conversationWithPointers.pointers + .asSequence() + .mapNotNull { (key, value) -> + key.timestamp?.let { it to (value ?: MessageStatus.Unknown) } + } + .toMap(), + members = members.count(), + listeners = members.count { !it.isFullMember }, + isRoomOpen = conversation.isOpen, + hostId = host?.id, + roomInfoArgs = RoomInfoArgs( + roomId = conversation.id, + roomNumber = conversation.roomNumber, + ownerId = conversation.ownerId, + hostName = host?.memberName, + memberCount = members.count(), + messagingFeeQuarks = conversation.coverCharge.quarks + ) + ) + } + + Event.OnSendMessageForFee -> { state -> + state.copy(chattableState = ChattableState.TemporarilyEnabled) + } + + is Event.OnPointersUpdated -> { state -> + state.copy(pointers = event.pointers) + } + + is Event.OnTypingStarted -> { state -> + state.copy(showTypingIndicator = true) + } + + is Event.OnTypingStopped -> { state -> + state.copy(showTypingIndicator = false) + } + + is Event.OnUserTypingStarted -> { state -> + state.copy(isSelfTyping = true) + } + + is Event.OnUserTypingStopped -> { state -> + state.copy(isSelfTyping = false) + } + + is Event.OnShareRoomLink, + is Event.ShareRoom, + is Event.OnOpenStateChangedRequested, + is Event.OnOpenRoom, + is Event.OnCloseRoom, + is Event.OnRoomNumberChanged, + is Event.OnJoinRoom, + is Event.OnAccountCreated, + is Event.NeedsAccountCreated, + is Event.OnJoinRequestedFromSpectating, + is Event.Error, + Event.RevealIdentity, + Event.OnSendCash, + is Event.MarkRead, + is Event.MarkDelivered, + is Event.DeleteMessage, + is Event.CopyMessage, + is Event.RemoveUser, + is Event.ReportUser, + is Event.MuteUser, + is Event.BlockUser, + is Event.UnblockUser, + is Event.TipUser, + is Event.PromoteUser, + is Event.DemoteUser, + is Event.Resumed, + is Event.Stopped, + is Event.LookupRoom, + is Event.OpenRoomPreview, + is Event.OpenRoom, + is Event.OnSendMessage, + is Event.SendMessage, + is Event.SendMessageWithFee -> { state -> state } + + is Event.OnUserActivity -> { state -> + state.copy(lastSeen = event.activity) + } + + is Event.OnSelfChanged -> { state -> + state.copy( + selfId = event.id, + selfName = event.displayName + ) + } + + is Event.OnAbilityToChatChanged -> { state -> state.copy(chattableState = event.state) } + is Event.OnReplyEnabled -> { state -> state.copy(replyEnabled = event.enabled) } + is Event.OnStartAtUnread -> { state -> state.copy(startAtUnread = event.enabled) } + is Event.ReplyTo -> { state -> + state.copy(replyMessage = event.anchor) + } + + is Event.CancelReply -> { state -> state.copy(replyMessage = null) } + is Event.OnOpenCloseEnabled -> { state -> state.copy(isOpenCloseEnabled = event.enabled) } + is Event.OnTippingEnabled -> { state -> state.copy(isTippingEnabled = event.enabled) } + is Event.OnLinkImagePreviewsEnabled -> { state -> + state.copy( + isLinkImagePreviewsEnabled = event.enabled + ) + } + + Event.OnUnreadStateHandled -> { state -> state.copy(unreadStateHandled = true) } + } + } + } +} + +private fun findLastReadMessage( + statusMap: Map, +): UUID? { + return statusMap + .filter { it.value == MessageStatus.Read } + .maxByOrNull { it.key.timestamp ?: 0L } + ?.key +} + +private fun findClosestMessageStatus( + timestamp: Long?, + statusMap: Map, + fallback: MessageStatus +): MessageStatus { + timestamp ?: return fallback + var closestKey: Long? = null + + for (key in statusMap.keys) { + if (timestamp <= key && (closestKey == null || key <= closestKey)) { + closestKey = key + } + } + + return closestKey?.let { statusMap[it] } ?: fallback +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/MessageReplyAnchor.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/MessageReplyAnchor.kt new file mode 100644 index 000000000..2122dd73a --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/MessageReplyAnchor.kt @@ -0,0 +1,11 @@ +package xyz.flipchat.app.features.chat.conversation + +import com.getcode.model.ID +import com.getcode.model.chat.MessageContent +import com.getcode.model.chat.Sender + +data class MessageReplyAnchor( + val id: ID, + val sender: Sender, + val message: MessageContent +) \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/MessageTipsSheet.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/MessageTipsSheet.kt new file mode 100644 index 000000000..77568a380 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/conversation/MessageTipsSheet.kt @@ -0,0 +1,103 @@ +package xyz.flipchat.app.features.chat.conversation + +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen +import com.getcode.extensions.formattedRaw +import com.getcode.model.sum +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.R +import com.getcode.ui.components.chat.UserAvatar +import com.getcode.ui.components.chat.utils.MessageTip +import xyz.flipchat.services.internal.data.mapper.nullIfEmpty + +internal data class MessageTipsSheet(val tips: List) : Screen { + + @Composable + override fun Content() { + val userTips = remember { + tips.groupBy { it.tipper } + .mapValues { it.value.map { it.amount }.sum() } + .toList().sortedByDescending { it.second.fiat } + } + + val imageModifier = Modifier + .size(CodeTheme.dimens.staticGrid.x8) + .clip(CircleShape) + + + Column(modifier = Modifier.fillMaxWidth().fillMaxHeight(0.5f)) { + Text( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = CodeTheme.dimens.inset), + text = "Tips", + style = CodeTheme.typography.screenTitle + ) + LazyColumn( + modifier = Modifier.navigationBarsPadding(), + contentPadding = PaddingValues(CodeTheme.dimens.inset), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset), + ) { + items(userTips) { (tipper, amount) -> + Row( + modifier = Modifier.fillParentMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.CenterVertically, + ) { + UserAvatar( + modifier = imageModifier, + data = tipper.profileImage.nullIfEmpty() ?: tipper.id + ) { + Image( + modifier = Modifier.padding(5.dp), + imageVector = Icons.Default.Person, + colorFilter = ColorFilter.tint(Color.White), + contentDescription = null, + ) + } + Text( + modifier = Modifier.weight(1f), + text = tipper.displayName.orEmpty(), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.onSurface + ) + + Text( + text = stringResource( + R.string.title_kinAmountWithLogo, + amount.formattedRaw() + ), + color = CodeTheme.colors.onSurface, + style = CodeTheme.typography.textMedium, + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/cover/CoverChargeScreen.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/cover/CoverChargeScreen.kt new file mode 100644 index 000000000..7a9645814 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/cover/CoverChargeScreen.kt @@ -0,0 +1,113 @@ +package xyz.flipchat.app.features.chat.cover + +import android.os.Parcelable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +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.stringResource +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.hilt.getViewModel +import com.getcode.model.ID +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.screens.NamedScreen +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import xyz.flipchat.app.R +import xyz.flipchat.app.ui.AmountWithKeypad + +@Parcelize +data class CoverChargeScreen(val roomId: ID) : Screen, NamedScreen, Parcelable { + + @IgnoredOnParcel + override val key: ScreenKey = uniqueScreenKey + + override val name: String + @Composable get() = stringResource(R.string.title_changeMessageFee) + + + @Composable + override fun Content() { + val viewModel = getViewModel() + val navigator = LocalCodeNavigator.current + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = name, + backButton = true, + onBackIconClicked = navigator::pop + ) + ChangeCoverScreenContent(viewModel) + } + + LaunchedEffect(viewModel) { + viewModel.dispatchEvent(CoverChargeViewModel.Event.OnRoomIdChanged(roomId)) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.pop() + }.launchIn(this) + } + } +} + +@Composable +private fun ChangeCoverScreenContent( + viewModel: CoverChargeViewModel, +) { + val state by viewModel.stateFlow.collectAsState() + Column( + modifier = Modifier.fillMaxSize(), + ) { + AmountWithKeypad( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + amountAnimatedModel = state.amountAnimatedModel, + isKin = true, + placeholder = "0", + onNumberPressed = { viewModel.dispatchEvent(CoverChargeViewModel.Event.OnNumberPressed(it)) }, + onBackspace = { viewModel.dispatchEvent(CoverChargeViewModel.Event.OnBackspace) }, + ) + + Box(modifier = Modifier.fillMaxWidth()) { + CodeButton( + enabled = state.canChange, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x2) + .navigationBarsPadding(), + buttonState = ButtonState.Filled, + text = stringResource(R.string.action_saveChanges), + isLoading = state.submitting, + isSuccess = state.success, + ) { + viewModel.dispatchEvent(CoverChargeViewModel.Event.OnChangeFee) + } + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/cover/CoverChargeViewModel.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/cover/CoverChargeViewModel.kt new file mode 100644 index 000000000..2dec10f25 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/cover/CoverChargeViewModel.kt @@ -0,0 +1,134 @@ +package xyz.flipchat.app.features.chat.cover + +import androidx.lifecycle.viewModelScope +import com.getcode.manager.TopBarManager +import com.getcode.model.ID +import com.getcode.model.KinAmount +import com.getcode.ui.components.text.AmountAnimatedInputUiModel +import com.getcode.ui.components.text.NumberInputHelper +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel2 +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import xyz.flipchat.app.R +import xyz.flipchat.app.features.login.register.onResult +import xyz.flipchat.chat.RoomController +import javax.inject.Inject + +@HiltViewModel +class CoverChargeViewModel @Inject constructor( + roomController: RoomController, + resources: ResourceHelper, +) : BaseViewModel2( + initialState = State(), + updateStateForEvent = updateStateForEvent +) { + private val numberInputHelper = NumberInputHelper() + + data class State( + val roomId: ID? = null, + val submitting: Boolean = false, + val success: Boolean = false, + val amountAnimatedModel: AmountAnimatedInputUiModel = AmountAnimatedInputUiModel(), + val canChange: Boolean = false, + ) + + sealed interface Event { + data class OnRoomIdChanged(val roomId: ID) : Event + data class OnNumberPressed(val number: Int) : Event + data object OnBackspace : Event + data class OnEnteredNumberChanged(val backspace: Boolean = false) : Event + data class OnCoverChanged(val amountAnimatedModel: AmountAnimatedInputUiModel) : Event + data object OnChangeFee : Event + data class OnChangingFee(val changing: Boolean): Event + data object OnFeeChangedSuccessfully : Event + } + + init { + numberInputHelper.reset() + + eventFlow + .filterIsInstance() + .map { it.number } + .onEach { number -> + numberInputHelper.maxLength = 10 // 1 billion Kin + numberInputHelper.onNumber(number) + dispatchEvent(Event.OnEnteredNumberChanged()) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + numberInputHelper.onBackspace() + dispatchEvent(Event.OnEnteredNumberChanged(true)) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.backspace } + .onEach { backspace -> + val current = stateFlow.value.amountAnimatedModel + val model = stateFlow.value.amountAnimatedModel + val amount = numberInputHelper.getFormattedStringForAnimation(includeCommas = true) + + val updated = model.copy( + amountDataLast = current.amountData, + amountData = amount, + lastPressedBackspace = backspace + ) + + dispatchEvent(Event.OnCoverChanged(updated)) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { dispatchEvent(Event.OnChangingFee(true)) } + .mapNotNull { + stateFlow.value.roomId ?: return@mapNotNull null + stateFlow.value.roomId!! to stateFlow.value.amountAnimatedModel.amountData.amount.toLong() + }.map { (roomId, value) -> + roomController.setMessagingFee(roomId, KinAmount.fromQuarks(value)) + }.onResult( + onError = { + dispatchEvent(Event.OnChangingFee(false)) + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToChangeMessageFee), + resources.getString(R.string.error_description_failedToChangeMessageFee) + ) + ) + }, + onSuccess = { + dispatchEvent(Event.OnChangingFee(false)) + dispatchEvent(Event.OnFeeChangedSuccessfully) + } + ).launchIn(viewModelScope) + } + + companion object { + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + is Event.OnChangingFee -> { state -> state.copy(submitting = event.changing) } + Event.OnFeeChangedSuccessfully -> { state -> state.copy(success = true) } + Event.OnBackspace, + is Event.OnEnteredNumberChanged, + is Event.OnChangeFee, + is Event.OnNumberPressed -> { state -> state } + + is Event.OnCoverChanged -> { state -> + val cover = event.amountAnimatedModel.amountData.amount.toLongOrNull() + state.copy( + amountAnimatedModel = event.amountAnimatedModel, + canChange = (cover ?: 0) > 0 + ) + } + + is Event.OnRoomIdChanged -> { state -> state.copy(roomId = event.roomId) } + } + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/info/ChatInfoViewModel.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/info/ChatInfoViewModel.kt new file mode 100644 index 000000000..25dd9ceb7 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/info/ChatInfoViewModel.kt @@ -0,0 +1,554 @@ +package xyz.flipchat.app.features.chat.info + +import android.content.Intent +import androidx.lifecycle.viewModelScope +import com.getcode.manager.BottomBarManager +import com.getcode.manager.TopBarManager +import com.getcode.model.ID +import com.getcode.model.Kin +import com.getcode.model.chat.MinimalMember +import com.getcode.navigation.RoomInfoArgs +import com.getcode.solana.keys.PublicKey +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel2 +import com.getcode.view.LoadingSuccessState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import xyz.flipchat.app.R +import xyz.flipchat.app.data.RoomInfo +import xyz.flipchat.app.features.chat.conversation.ConversationViewModel +import xyz.flipchat.app.features.chat.conversation.ConversationViewModel.Event +import xyz.flipchat.app.features.login.register.onResult +import xyz.flipchat.app.util.IntentUtils +import xyz.flipchat.chat.RoomController +import xyz.flipchat.controllers.ChatsController +import xyz.flipchat.services.extensions.titleOrFallback +import xyz.flipchat.services.internal.data.mapper.nullIfEmpty +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject + +sealed interface MemberType { + data object Speaker : MemberType + data object Listener : MemberType +} + +@HiltViewModel +class ChatInfoViewModel @Inject constructor( + private val roomController: RoomController, + private val chatsController: ChatsController, + private val resources: ResourceHelper, + private val userManager: UserManager, +) : BaseViewModel2( + initialState = State(), + updateStateForEvent = updateStateForEvent +) { + + data class State( + val isPreview: Boolean = false, + val isHost: Boolean = false, + val isMember: Boolean = false, + val paymentDestination: PublicKey? = null, + val roomNameChangesEnabled: Boolean = false, + val isOpen: Boolean = false, + val roomInfo: RoomInfo = RoomInfo(), + val joining: LoadingSuccessState = LoadingSuccessState(), + val leaving: LoadingSuccessState = LoadingSuccessState(), + val members: Map> = emptyMap() + ) + + sealed interface Event { + // region state updates + data class OnRoomNameChangesEnabled(val enabled: Boolean) : Event + data class OnHostStatusChanged(val isHost: Boolean) : Event + data class OnRoomOpenStateChanged(val isOpen: Boolean) : Event + data class OnDestinationChanged(val destination: PublicKey) : Event + data class OnInfoChanged(val args: RoomInfoArgs, val isPreview: Boolean) : Event + data class OnMembersUpdated(val members: List) : Event + // endregion state updates + + // region action/reaction + data class OnChangeMessageFee(val roomId: ID) : Event + data class OnFeeChanged(val cover: Kin) : Event + + data class OnChangeName(val id: ID, val title: String) : Event + data class OnNameChanged(val name: String) : Event + + data object OnShareRoomClicked : Event + data class ShareRoom(val intent: Intent) : Event + + data object OnListenToClicked : Event + data class OnJoiningStateChanged(val joining: Boolean, val joined: Boolean = false) : Event + data class OnBecameMember(val roomId: ID) : Event + + data object OnOpenStateChangedRequested : Event + data class OnOpenRoom(val conversationId: ID) : Event + data class OnCloseRoom(val conversationId: ID) : Event + + data class PromoteRequested(val member: MinimalMember) : Event + data class PromoteUser(val conversationId: ID, val userId: ID) : Event + data class OnUserPromoted(val id: ID) : Event + data class DemoteRequested(val member: MinimalMember) : Event + data class DemoteUser(val conversationId: ID, val userId: ID) : Event + data class OnUserDemoted(val id: ID) : Event + + data object LeaveRoom : Event + data class OnLeavingStateChanged(val leaving: Boolean, val left: Boolean = false) : Event + data object OnLeaveRoomConfirmed : Event + // endregion action/reaction + + data object OnLeftRoom : Event + } + + init { + eventFlow + .filterIsInstance() + .map { it.args.ownerId } + .map { hostId -> userManager.userId == hostId } + .onEach { isHost -> + dispatchEvent(Event.OnHostStatusChanged(isHost)) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { it.args } + .onEach { args -> + val exists = roomController.getConversation(args.roomId.orEmpty()) != null + if (!exists) { + chatsController.lookupRoom(args.roomNumber) + .onSuccess { (room, members) -> + dispatchEvent(Event.OnRoomOpenStateChanged(room.isOpen)) + dispatchEvent(Event.OnNameChanged(room.titleOrFallback(resources))) + dispatchEvent( + Event.OnMembersUpdated( + members.map { m -> + MinimalMember( + id = m.id, + displayName = m.identity?.displayName.nullIfEmpty(), + profileImageUrl = m.identity?.imageUrl.nullIfEmpty(), + canSpeak = m.isModerator || !m.isSpectator, + isHost = m.isModerator, + isSelf = userManager.isSelf(m.id), + ) + } + ) + ) + dispatchEvent(Event.OnFeeChanged(room.messagingFee)) + } + } else { + roomController.observeConversation(args.roomId.orEmpty()) + .filterNotNull() + .map { Triple(it.conversation, it.members, it.conversation.coverCharge) } + .onEach { (conversation, members, cover) -> + dispatchEvent(Event.OnRoomOpenStateChanged(conversation.isOpen)) + dispatchEvent(Event.OnNameChanged(conversation.titleOrFallback(resources))) + dispatchEvent( + Event.OnMembersUpdated( + members.map { m -> + MinimalMember( + id = m.id, + displayName = m.memberName.nullIfEmpty(), + profileImageUrl = m.imageUri, + isHost = m.isHost, + canSpeak = m.isFullMember, + isSelf = userManager.isSelf(m.id), + ) + } + ) + ) + dispatchEvent(Event.OnFeeChanged(cover)) + }.launchIn(viewModelScope) + } + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { stateFlow.value.roomInfo.number } + .onEach { roomNumber -> + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = resources.getString(R.string.title_leaveRoom), + subtitle = resources.getString(R.string.subtitle_leaveRoom), + positiveText = resources.getString( + R.string.action_leaveRoomByName, + resources.getString(R.string.title_implicitRoomTitle, roomNumber) + ), + negativeText = "", + tertiaryText = resources.getString(R.string.action_cancel), + onPositive = { dispatchEvent(Event.OnLeaveRoomConfirmed) }, + onNegative = { }, + type = BottomBarManager.BottomBarMessageType.DESTRUCTIVE, + showScrim = true, + ) + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { stateFlow.value.roomInfo } + .onEach { roomInfo -> + dispatchEvent(Event.OnJoiningStateChanged(true)) + chatsController.joinRoomAsSpectator(roomInfo.id.orEmpty()) + .onFailure { + dispatchEvent(Event.OnJoiningStateChanged(false)) + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToFollowRoom), + resources.getString( + R.string.error_description_failedToFollowRoom, + stateFlow.value.roomInfo.title + ) + ) + ) + }.onSuccess { + dispatchEvent(Event.OnBecameMember(it.room.id)) + } + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { stateFlow.value.roomInfo.id } + .mapNotNull { + if (it == null) { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + title = resources.getString(R.string.error_title_failedToLeaveRoom), + message = resources.getString(R.string.error_description_failedToLeaveRoom) + ) + ) + return@mapNotNull null + } + dispatchEvent(Event.OnLeavingStateChanged(true)) + roomController.leaveRoom(it) + }.onResult( + onError = { + dispatchEvent(Event.OnLeavingStateChanged(false)) + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + title = resources.getString(R.string.error_title_failedToLeaveRoom), + message = resources.getString(R.string.error_description_failedToLeaveRoom) + ) + ) + }, + onSuccess = { + dispatchEvent(Event.OnLeavingStateChanged(leaving = false, left = true)) + dispatchEvent(Event.OnLeftRoom) + } + ).launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { stateFlow.value.roomInfo.id } + .map { it to stateFlow.value.isOpen } + .onEach { (conversationId, isOpen) -> + confirmOpenStateChange(conversationId, isOpen) + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.conversationId } + .map { roomController.enableChat(it) } + .onResult( + onError = { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToReopenRoom), + resources.getString(R.string.error_description_failedToReopenRoom) + ) + ) + }, + ).launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.conversationId } + .map { roomController.disableChat(it) } + .onResult( + onError = { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToCloseRoom), + resources.getString(R.string.error_description_failedToCloseRoom) + ) + ) + }, + ).launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { IntentUtils.shareRoom(stateFlow.value.roomInfo.roomNumber) } + .onEach { dispatchEvent(Event.ShareRoom(it)) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.member } + .onEach { + confirmUserPromote( + conversationId = stateFlow.value.roomInfo.id.orEmpty(), + userId = it.id.orEmpty(), + user = it.displayName + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.member } + .onEach { + confirmUserDemote( + conversationId = stateFlow.value.roomInfo.id.orEmpty(), + userId = it.id.orEmpty(), + user = it.displayName + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { member -> + roomController.promoteUser(member.conversationId, member.userId) + .onFailure { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToPromoteUser), + resources.getString(R.string.error_description_failedToPromoteUser) + ) + ) + }.onSuccess { + dispatchEvent(Event.OnUserPromoted(member.userId)) + } + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { member -> + roomController.demoteUser(member.conversationId, member.userId) + .onFailure { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToDemoteUser), + resources.getString(R.string.error_description_failedToDemoteUser) + ) + ) + }.onSuccess { + dispatchEvent(Event.OnUserDemoted(member.userId)) + } + }.launchIn(viewModelScope) + } + + private fun confirmOpenStateChange(conversationId: ID, isRoomOpen: Boolean) { + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = if (isRoomOpen) resources.getString(R.string.prompt_title_closeRoom) else resources.getString( + R.string.prompt_title_reopenRoom + ), + subtitle = if (isRoomOpen) resources.getString(R.string.prompt_description_closeRoom) else resources.getString( + R.string.prompt_description_reopenRoom + ), + positiveText = if (isRoomOpen) resources.getString(R.string.action_closeTemporarily) else resources.getString( + R.string.action_reopenRoom + ), + tertiaryText = resources.getString(R.string.action_cancel), + onPositive = { + if (isRoomOpen) { + dispatchEvent(Event.OnCloseRoom(conversationId)) + } else { + dispatchEvent(Event.OnOpenRoom(conversationId)) + } + }, + type = BottomBarManager.BottomBarMessageType.THEMED, + showScrim = true, + ) + ) + } + + private fun confirmUserPromote(conversationId: ID, user: String?, userId: ID) { + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = resources.getString( + R.string.title_promoteUserInRoom, + user.orEmpty().ifEmpty { "User" }), + subtitle = resources.getString(R.string.subtitle_promoteUserInRoom), + positiveText = resources.getString(R.string.action_promote), + negativeText = "", + tertiaryText = resources.getString(R.string.action_cancel), + onPositive = { dispatchEvent(Event.PromoteUser(conversationId, userId)) }, + onNegative = { }, + type = BottomBarManager.BottomBarMessageType.THEMED, + showScrim = true, + ) + ) + } + + private fun confirmUserDemote(conversationId: ID, user: String?, userId: ID) { + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = resources.getString( + R.string.title_demoteUserInRoom, + user.orEmpty().ifEmpty { "User" }), + subtitle = resources.getString(R.string.subtitle_demoteUserInRoom), + positiveText = resources.getString(R.string.action_demote), + negativeText = "", + tertiaryText = resources.getString(R.string.action_cancel), + onPositive = { dispatchEvent(Event.DemoteUser(conversationId, userId)) }, + onNegative = { }, + type = BottomBarManager.BottomBarMessageType.DESTRUCTIVE, + showScrim = true, + ) + ) + } + + companion object { + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + (when (event) { + Event.LeaveRoom -> { state -> state } + is Event.OnInfoChanged -> { state -> + val args = event.args + state.copy( + isPreview = event.isPreview, + roomInfo = RoomInfo( + id = args.roomId, + number = args.roomNumber, + title = args.roomTitle.orEmpty(), + memberCount = args.memberCount, + hostId = args.ownerId, + hostName = args.hostName, + roomNumber = args.roomNumber, + messagingFee = Kin.fromQuarks(args.messagingFeeQuarks) + ) + ) + } + + is Event.PromoteRequested, + is Event.PromoteUser, + is Event.DemoteRequested, + is Event.DemoteUser, + is Event.OnChangeMessageFee, + Event.OnLeaveRoomConfirmed, + is Event.OnChangeName, + is Event.OnShareRoomClicked, + is Event.ShareRoom, + is Event.OnListenToClicked, + is Event.OnBecameMember, + is Event.OnOpenStateChangedRequested, + is Event.OnCloseRoom, + is Event.OnOpenRoom, + Event.OnLeftRoom -> { state -> state } + + is Event.OnUserPromoted -> { state -> + val members = state.members.flatMap { it.value } + val updatedMembers = members.map { + if (it.id == event.id) { + it.copy(canSpeak = true) + } else { + it + } + } + + val groupedMembers = updatedMembers + .groupBy { it.canSpeak } + .mapKeys { + if (it.key) { + MemberType.Speaker + } else { + MemberType.Listener + } + }.mapValues { it.value.sortedByDescending { it.isHost } } + + state.copy(members = groupedMembers) + } + + is Event.OnUserDemoted -> { state -> + val members = state.members.flatMap { it.value } + val updatedMembers = members.map { + if (it.id == event.id) { + it.copy(canSpeak = false) + } else { + it + } + } + + val groupedMembers = updatedMembers + .groupBy { it.canSpeak } + .mapKeys { + if (it.key) { + MemberType.Speaker + } else { + MemberType.Listener + } + }.mapValues { it.value.sortedByDescending { it.isHost } } + + state.copy(members = groupedMembers) + } + + is Event.OnHostStatusChanged -> { state -> state.copy(isHost = event.isHost) } + is Event.OnFeeChanged -> { state -> + state.copy( + roomInfo = state.roomInfo.copy( + messagingFee = event.cover, + ) + ) + } + + is Event.OnNameChanged -> { state -> + state.copy( + roomInfo = state.roomInfo.copy( + title = event.name, + ) + ) + } + + is Event.OnMembersUpdated -> { state -> + val groupedMembers = event.members + .groupBy { it.canSpeak } + .mapKeys { + if (it.key) { + MemberType.Speaker + } else { + MemberType.Listener + } + }.mapValues { it.value.sortedByDescending { it.isHost } } + + state.copy( + roomInfo = state.roomInfo.copy( + memberCount = event.members.count(), + ), + isMember = event.members.any { it.isSelf }, + members = groupedMembers, + ) + } + + is Event.OnRoomNameChangesEnabled -> { state -> + state.copy( + roomNameChangesEnabled = event.enabled + ) + } + + is Event.OnDestinationChanged -> { state -> state.copy(paymentDestination = event.destination) } + is Event.OnJoiningStateChanged -> { state -> + state.copy( + joining = state.joining.copy( + loading = event.joining, + success = event.joined + ) + ) + } + + is Event.OnLeavingStateChanged -> { state -> + state.copy( + leaving = state.joining.copy( + loading = event.leaving, + success = event.left + ) + ) + } + + is Event.OnRoomOpenStateChanged -> { state -> state.copy(isOpen = event.isOpen) } + }) + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/info/RoomControlAction.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/info/RoomControlAction.kt new file mode 100644 index 000000000..2146192fb --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/info/RoomControlAction.kt @@ -0,0 +1,52 @@ +package xyz.flipchat.app.features.chat.info + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Logout +import androidx.compose.material.icons.outlined.Bedtime +import androidx.compose.material.icons.outlined.LightMode +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.getcode.ui.components.contextmenu.ContextMenuAction +import xyz.flipchat.app.R + + +sealed interface RoomControlAction : ContextMenuAction { + data class MessageFee(override val onSelect: () -> Unit) : RoomControlAction { + override val isDestructive: Boolean = false + override val delayUponSelection: Boolean = true + override val title: String + @Composable get() = stringResource(R.string.action_changeMessagingFee) + override val painter: Painter + @Composable get() = painterResource(R.drawable.ic_kin_white_small) + } + + data class CloseRoom(override val onSelect: () -> Unit) : RoomControlAction { + override val isDestructive: Boolean = false + override val delayUponSelection: Boolean = true + override val title: String + @Composable get() = stringResource(R.string.action_closeFlipchatTemporarily) + override val painter: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.Bedtime) + } + + data class OpenRoom(override val onSelect: () -> Unit) : RoomControlAction { + override val isDestructive: Boolean = false + override val delayUponSelection: Boolean = true + override val title: String + @Composable get() = stringResource(R.string.action_reopenFlipchat) + override val painter: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.LightMode) + } + + data class LeaveRoom(override val onSelect: () -> Unit) : RoomControlAction { + override val isDestructive: Boolean = false + override val delayUponSelection: Boolean = true + override val title: String + @Composable get() = stringResource(R.string.action_leaveRoom) + override val painter: Painter + @Composable get() = rememberVectorPainter(Icons.AutoMirrored.Outlined.Logout) + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/info/RoomInfoScreen.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/info/RoomInfoScreen.kt new file mode 100644 index 000000000..aabf411d6 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/info/RoomInfoScreen.kt @@ -0,0 +1,467 @@ +package xyz.flipchat.app.features.chat.info + +import android.os.Parcelable +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.outlined.BorderColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +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.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.hilt.getViewModel +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.RoomInfoArgs +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.screens.ContextMenuStyle +import com.getcode.navigation.screens.ContextSheet +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.chat.AvatarEndAction +import com.getcode.ui.components.chat.HostableAvatar +import com.getcode.ui.components.contextmenu.ContextMenuAction +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.utils.isVerticallyScrolledToStart +import com.getcode.ui.utils.rememberedLongClickable +import com.getcode.ui.utils.unboundedClickable +import com.getcode.ui.utils.verticalScrollStateGradient +import com.getcode.utils.base58 +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import xyz.flipchat.app.R +import xyz.flipchat.app.features.home.TabbedHomeScreen + +@Parcelize +class RoomInfoScreen( + private val info: RoomInfoArgs, + private val isPreview: Boolean, + private val returnToSender: Boolean +) : Screen, Parcelable { + + @IgnoredOnParcel + override val key: ScreenKey = uniqueScreenKey + + @Composable + override fun Content() { + val viewModel = getViewModel() + val navigator = LocalCodeNavigator.current + val context = LocalContext.current + + LaunchedEffect(info) { + viewModel.dispatchEvent(ChatInfoViewModel.Event.OnInfoChanged(info, isPreview)) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.popUntil { it is TabbedHomeScreen } + }.launchIn(this) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.roomId } + .onEach { + navigator.push(ScreenRegistry.get(NavScreenProvider.Room.Messages(it))) + }.launchIn(this) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.roomId } + .onEach { + navigator.push( + ScreenRegistry.get(NavScreenProvider.Room.ChangeCover(it)), + delay = 100 + ) + }.launchIn(this) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + context.startActivity(it.intent) + }.launchIn(this) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.push( + ScreenRegistry.get( + NavScreenProvider.Room.ChangeName( + it.id, + it.title + ) + ) + ) + }.launchIn(this) + } + + val state by viewModel.stateFlow.collectAsState() + + val goBack = { + if (!returnToSender) { + navigator.pop() + } else { + navigator.popUntil { it is TabbedHomeScreen } + } + } + + BackHandler { + goBack() + } + + val listState = rememberLazyGridState() + + val showTitle by remember(listState) { + derivedStateOf { listState.firstVisibleItemIndex >= 1 } + } + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = { + AnimatedVisibility( + visible = showTitle, + enter = slideInVertically { it } + fadeIn(), + exit = fadeOut() + slideOutVertically { it } + ) { + AppBarDefaults.Title( + text = state.roomInfo.customTitle.ifEmpty { state.roomInfo.title }, + style = CodeTheme.typography.screenTitle.copy(fontSize = 18.sp) + ) + } + }, + leftIcon = { + AppBarDefaults.UpNavigation { goBack() } + }, + rightContents = { + if (state.isMember) { + AppBarDefaults.Settings { + navigator.show( + ContextSheet( + buildActions( + state, + viewModel::dispatchEvent + ), + ContextMenuStyle.Themed + ) + ) + } + } + } + ) + RoomInfoScreenContent(listState, state, viewModel::dispatchEvent) + } + } +} + +@Composable +private fun RoomInfoScreenContent( + listState: LazyGridState, + state: ChatInfoViewModel.State, + dispatch: (ChatInfoViewModel.Event) -> Unit +) { + CodeScaffold( + bottomBar = { + if (!state.isMember) { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .navigationBarsPadding(), + buttonState = ButtonState.Filled, + text = stringResource(R.string.action_startListening), + ) { + dispatch(ChatInfoViewModel.Event.OnListenToClicked) + } + } + } + ) { padding -> + val speakers = remember(state.members) { + state.members.getOrDefault(MemberType.Speaker, emptyList()) + } + + val listeners = remember(state.members) { + state.members.getOrDefault(MemberType.Listener, emptyList()) + } + + LazyVerticalGrid( + modifier = Modifier + .padding(padding) + .navigationBarsPadding() + .verticalScrollStateGradient(listState, color = CodeTheme.colors.background), + state = listState, + columns = GridCells.Adaptive(CodeTheme.dimens.grid.x16), + contentPadding = PaddingValues( + top = CodeTheme.dimens.grid.x6, + start = CodeTheme.dimens.inset, + end = CodeTheme.dimens.inset, + bottom = CodeTheme.dimens.inset, + ), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + // header + Column( + modifier = Modifier + .fillMaxWidth() + .unboundedClickable( + enabled = state.isHost + ) { + dispatch( + ChatInfoViewModel.Event.OnChangeName( + state.roomInfo.id!!, + state.roomInfo.customTitle + ) + ) + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2) + ) { + HostableAvatar( + size = CodeTheme.dimens.grid.x20, + imageData = state.roomInfo.imageUrl ?: state.roomInfo.id, + overlay = { + Image( + modifier = Modifier.size(CodeTheme.dimens.grid.x12), + painter = painterResource(R.drawable.ic_fc_chats), + colorFilter = ColorFilter.tint(Color.White), + contentDescription = null, + ) + }, + endAction = AvatarEndAction.Icon( + icon = rememberVectorPainter(Icons.Outlined.BorderColor), + contentColor = Color.White, + backgroundColor = CodeTheme.colors.indicator + ).takeIf { state.isHost }, + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = state.roomInfo.customTitle.ifEmpty { state.roomInfo.title }, + style = CodeTheme.typography.screenTitle, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center + ) + Crossfade(state.isOpen) { open -> + Text( + text = if (open) "" else stringResource(R.string.subtitle_roomInfoRoomIsClosed), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary.copy(0.54f), + ) + } + } + } + } + + if (state.isMember) { + item(span = { GridItemSpan(maxLineSpan) }) { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(top = CodeTheme.dimens.grid.x4), + buttonState = ButtonState.Filled, + text = if (state.isHost) { + stringResource(R.string.action_shareRoomLinkAsHost) + } else { + stringResource(R.string.action_shareRoomLinkAsMember) + }, + ) { + dispatch(ChatInfoViewModel.Event.OnShareRoomClicked) + } + } + } + + item(span = { GridItemSpan(maxLineSpan) }) { + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3), + text = pluralStringResource( + R.plurals.title_roomInfoSpeakerCount, + speakers.count(), + speakers.count() + ), + style = CodeTheme.typography.screenTitle, + color = CodeTheme.colors.textMain + ) + } + + items(speakers, key = { it.id?.base58.orEmpty() }) { member -> + Column( + modifier = Modifier.rememberedLongClickable( + enabled = state.isHost && !member.isSelf + ) { + dispatch(ChatInfoViewModel.Event.DemoteRequested(member)) + }.animateItem(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2) + ) { + HostableAvatar( + size = CodeTheme.dimens.grid.x15, + isHost = member.isHost, + imageData = member.imageData, + ) { + Image( + modifier = Modifier.size(CodeTheme.dimens.grid.x8), + imageVector = Icons.Default.Person, + colorFilter = ColorFilter.tint(Color.White), + contentDescription = null, + ) + } + Text( + text = when { + member.isSelf -> stringResource(R.string.subtitle_you) + else -> member.displayName ?: stringResource(R.string.title_listener) + }, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + item(span = { GridItemSpan(maxLineSpan) }) { + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x4), + text = pluralStringResource( + R.plurals.title_roomInfoListenerCount, + listeners.count(), + listeners.count() + ), + style = CodeTheme.typography.screenTitle, + color = CodeTheme.colors.textMain + ) + } + + items(listeners, key = { it.id?.base58.orEmpty() }) { member -> + Column( + modifier = Modifier.rememberedLongClickable( + enabled = state.isHost && !member.isSelf + ) { + dispatch(ChatInfoViewModel.Event.PromoteRequested(member)) + }.animateItem(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2) + ) { + HostableAvatar( + size = CodeTheme.dimens.grid.x15, + isHost = member.isHost, + imageData = member.imageData + ) { + Image( + modifier = Modifier.size(CodeTheme.dimens.grid.x8), + imageVector = Icons.Default.Person, + colorFilter = ColorFilter.tint(Color.White), + contentDescription = null, + ) + } + Text( + text = when { + member.isSelf -> stringResource(R.string.subtitle_you) + else -> member.displayName ?: stringResource(R.string.title_listener) + }, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} + +private fun buildActions( + state: ChatInfoViewModel.State, + dispatch: (ChatInfoViewModel.Event) -> Unit, +): List { + return buildList { + if (state.isHost) { + add( + RoomControlAction.MessageFee { + dispatch(ChatInfoViewModel.Event.OnChangeMessageFee(state.roomInfo.id!!)) + } + ) + if (state.isOpen) { + add( + RoomControlAction.CloseRoom { + dispatch(ChatInfoViewModel.Event.OnOpenStateChangedRequested) + } + ) + } else { + add( + RoomControlAction.OpenRoom { + dispatch(ChatInfoViewModel.Event.OnOpenStateChangedRequested) + } + ) + } + } + + add( + RoomControlAction.LeaveRoom { + dispatch(ChatInfoViewModel.Event.LeaveRoom) + } + ) + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/list/ChatListViewModel.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/list/ChatListViewModel.kt new file mode 100644 index 000000000..2f6f38bd9 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/list/ChatListViewModel.kt @@ -0,0 +1,291 @@ +package xyz.flipchat.app.features.chat.list + +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import com.getcode.manager.TopBarManager +import com.getcode.model.ID +import com.getcode.model.Kin +import com.getcode.model.KinAmount +import com.getcode.model.kin +import com.getcode.services.model.ExtendedMetadata +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.network.NetworkConnectivityListener +import com.getcode.view.BaseViewModel2 +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.take +import xyz.flipchat.app.R +import xyz.flipchat.app.features.login.register.onError +import xyz.flipchat.controllers.ChatsController +import xyz.flipchat.controllers.ProfileController +import xyz.flipchat.services.PaymentController +import xyz.flipchat.services.PaymentEvent +import xyz.flipchat.services.data.metadata.StartGroupChatPaymentMetadata +import xyz.flipchat.services.data.metadata.erased +import xyz.flipchat.services.data.metadata.typeUrl +import xyz.flipchat.services.domain.model.chat.ConversationWithMembersAndLastMessage +import xyz.flipchat.services.extensions.titleOrFallback +import xyz.flipchat.services.user.AuthState +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject + +@HiltViewModel +class ChatListViewModel @Inject constructor( + userManager: UserManager, + private val chatsController: ChatsController, + private val paymentController: PaymentController, + private val profileController: ProfileController, + networkObserver: NetworkConnectivityListener, + resources: ResourceHelper, +) : BaseViewModel2( + initialState = State(userManager.userId), + updateStateForEvent = updateStateForEvent +) { + data class State( + val selfId: ID? = null, + val showScrim: Boolean = false, + val showFullscreenSpinner: Boolean = false, + val networkConnected: Boolean = true, + val chatTapCount: Int = 0, + val isLoggedIn: Boolean = false, + val isLogOutEnabled: Boolean = false, + val createRoomCost: Kin = 0.kin, + ) + + sealed interface Event { + data class OnSelfIdChanged(val id: ID?) : Event + data class OnLoggedInStateChanged(val loggedIn: Boolean) : Event + data class ShowFullScreenSpinner( + val showScrim: Boolean = true, + val showSpinner: Boolean = true + ) : + Event + + data class OnCreateCostChanged(val cost: Kin): Event + data class OnNetworkChanged(val connected: Boolean) : Event + data object OnOpen : Event + data object CreateRoomSelected : Event + data object CreateRoom : Event + data object NeedsAccountCreated : Event + data object OnAccountCreated : Event + data class OpenRoom(val roomId: ID) : Event + data object OnChatsTapped : Event + data class MuteRoom(val roomId: ID) : Event + data class UnmuteRoom(val roomId: ID) : Event + data object OnLogOutUnlocked : Event + } + + init { + userManager.state + .map { it.userId } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnSelfIdChanged(it)) } + .launchIn(viewModelScope) + + userManager.state + .mapNotNull { it.flags } + .map { it.createCost } + .onEach { dispatchEvent(Event.OnCreateCostChanged(it)) } + .launchIn(viewModelScope) + + userManager.state + .map { it.authState } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnLoggedInStateChanged(it is AuthState.LoggedIn)) } + .launchIn(viewModelScope) + + networkObserver.state + .map { it.connected } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnNetworkChanged(it)) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { stateFlow.value.chatTapCount } + .filter { it >= TAP_THRESHOLD } + .filterNot { stateFlow.value.isLogOutEnabled } + .onEach { dispatchEvent(Event.OnLogOutUnlocked) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.roomId } + .map { chatsController.muteRoom(it) } + .onError { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToMuteChat), + resources.getString(R.string.error_description_failedToMuteChat) + ) + ) + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.roomId } + .map { chatsController.unmuteRoom(it) } + .onError { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToUnmuteChat), + resources.getString(R.string.error_description_failedToUnmuteChat) + ) + ) + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { userManager.authState } + .onEach { + if (it is AuthState.LoggedIn) { + dispatchEvent(Event.CreateRoom) + } else { + dispatchEvent(Event.NeedsAccountCreated) + } + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { delay(400) } + .onEach { dispatchEvent(Event.CreateRoom) } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .map { profileController.getUserFlags() } + .mapNotNull { + it.exceptionOrNull()?.let { + return@mapNotNull null + } + + it.getOrNull()?.let { flags -> + val startGroupMetadata = StartGroupChatPaymentMetadata( + userId = userManager.userId!! + ) + + val metadata = ExtendedMetadata.Any( + data = startGroupMetadata.erased(), + typeUrl = startGroupMetadata.typeUrl + ) + + val amount = + KinAmount.fromQuarks(flags.createCost.quarks) + + paymentController.presentPublicPaymentConfirmation( + amount = amount, + destination = flags.feeDestination, + metadata = metadata + ) + } + }.flatMapLatest { + paymentController.eventFlow.take(1) + }.onEach { event -> + when (event) { + PaymentEvent.OnPaymentCancelled -> Unit + is PaymentEvent.OnPaymentError -> Unit + + is PaymentEvent.OnPaymentSuccess -> { + chatsController.createGroup( + title = null, + participants = emptyList(), + paymentId = event.intentId + ).onFailure { + event.acknowledge(false) { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToCreateRoom), + resources.getString(R.string.error_description_failedToCreateRoom) + ) + ) + } + }.onSuccess { + event.acknowledge(true) { + dispatchEvent(Event.OpenRoom(it.room.id)) + } + } + } + } + }.launchIn(viewModelScope) + } + + val chats: Flow> = + userManager.state + .map { it.authState } + .map { it.canOpenChatStream() } + .distinctUntilChanged() + .flatMapLatest { canOpen -> + if (canOpen) { + chatsController.chats.flow + } else { + flowOf(PagingData.empty()) + } + }.map { page -> + page.map { + it.copy( + conversation = it.conversation.copy( + title = it.conversation.titleOrFallback( + resources = resources, + ) + ) + ) + } + } + .cachedIn(viewModelScope) + + companion object { + private const val TAP_THRESHOLD = 6 + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + is Event.OnNetworkChanged -> { state -> + state.copy(networkConnected = event.connected) + } + + is Event.OnCreateCostChanged -> { state -> + state.copy(createRoomCost = event.cost) + } + + is Event.OnChatsTapped -> { state -> + if (state.chatTapCount >= TAP_THRESHOLD) state + else state.copy(chatTapCount = state.chatTapCount + 1) + } + + is Event.OnLogOutUnlocked -> { state -> state.copy(isLogOutEnabled = true) } + is Event.OpenRoom -> { state -> state } + is Event.ShowFullScreenSpinner -> { state -> + state.copy( + showFullscreenSpinner = event.showSpinner, + showScrim = event.showScrim + ) + } + + is Event.OnSelfIdChanged -> { state -> state.copy(selfId = event.id) } + + is Event.OnLoggedInStateChanged -> { state -> state.copy(isLoggedIn = event.loggedIn) } + + is Event.NeedsAccountCreated, + is Event.OnAccountCreated, + is Event.OnOpen, + is Event.CreateRoomSelected, + is Event.CreateRoom, + is Event.MuteRoom, + is Event.UnmuteRoom -> { state -> state } + } + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/list/ChatNode.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/list/ChatNode.kt new file mode 100644 index 000000000..408b10a3e --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/list/ChatNode.kt @@ -0,0 +1,189 @@ +package xyz.flipchat.app.features.chat.list + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.material.DismissDirection +import androidx.compose.material.DismissState +import androidx.compose.material.DismissValue +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.FixedThreshold +import androidx.compose.material.SwipeToDismiss +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeOff +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.navigator.currentOrThrow +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.chat.utils.localizedText +import com.getcode.util.vibration.LocalVibrator +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filter +import xyz.flipchat.app.R +import xyz.flipchat.app.ui.LocalUserManager +import xyz.flipchat.services.domain.model.chat.ConversationWithMembersAndLastMessage + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun ChatNode( + chat: ConversationWithMembersAndLastMessage, + modifier: Modifier = Modifier, + onToggleMute: (mute: Boolean) -> Unit = { }, + onClick: () -> Unit, +) { + val userManager = LocalUserManager.currentOrThrow + + val dismissState = rememberChatDismissState({ chat.isMuted }, onToggleMute) + + LaunchedEffect(dismissState) { + snapshotFlow { dismissState.currentValue } + .filter { it == DismissValue.DismissedToStart } + .collect { + dismissState.animateTo(DismissValue.Default) + } + } + + var muteContentState by remember { mutableStateOf(chat.isMuted) } + + LaunchedEffect(chat.id, chat.isMuted) { + delay(400) + muteContentState = chat.isMuted + } + + SwipeToDismiss( + state = dismissState, + dismissThresholds = { FixedThreshold(150.dp) }, + directions = if (chat.canChangeMuteState) setOf(DismissDirection.EndToStart) else emptySet(), + background = { + if (chat.canChangeMuteState) { + DismissBackground(dismissState, muteContentState) + } + } + ) { + com.getcode.ui.components.chat.ChatNode( + modifier = modifier.background(CodeTheme.colors.background), + title = chat.title, + messagePreview = chat.messagePreview, + messageTextStyle = CodeTheme.typography.textSmall, + messageMinLines = 2, + avatar = chat.imageUri ?: chat.id, + avatarIconWhenFallback = { + Image( + modifier = Modifier.padding(5.dp), + painter = painterResource(R.drawable.ic_fc_chats), + colorFilter = ColorFilter.tint(Color.White), + contentDescription = null, + ) + }, + timestamp = chat.lastMessage?.dateMillis, + isMuted = muteContentState, + isHost = chat.ownerId == userManager.userId, + unreadCount = chat.unreadCount, + showMoreUnread = chat.hasMoreUnread, + onClick = onClick + ) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun DismissBackground(dismissState: DismissState, isMuted: Boolean) { + val color = when (dismissState.dismissDirection) { + DismissDirection.EndToStart -> Color(0xFF251B4C) + else -> CodeTheme.colors.background + } + val direction = dismissState.dismissDirection + + Row( + modifier = Modifier + .fillMaxSize() + .background(color) + .padding(end = CodeTheme.dimens.inset), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + if (direction == DismissDirection.EndToStart) { + Image( + imageVector = if (isMuted) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff, + colorFilter = ColorFilter.tint(Color.White), + contentDescription = null + ) + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun rememberChatDismissState( + isChatMuted: () -> Boolean, + onToggleMute: (mute: Boolean) -> Unit +): DismissState { + val mutedState by rememberUpdatedState(isChatMuted()) + val vibrator = LocalVibrator.current + return remember { + DismissState( + initialValue = DismissValue.Default, + confirmStateChange = { + if (it == DismissValue.DismissedToStart) { + onToggleMute(!mutedState) + vibrator.tick() + true + } else false + } + ) + } +} + +private val ConversationWithMembersAndLastMessage.messagePreview: Pair> + @Composable get() { + val user = LocalUserManager.currentOrThrow + val contents = messageContentPreview ?: return AnnotatedString("No content") to emptyMap() + + val messageBody = buildAnnotatedString { + if (lastMessage?.isDeleted == true) { + when { + user.isSelf(lastMessage?.deletedBy) -> { + pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) + append(stringResource(R.string.title_messageDeletedByYou)) + pop() + } + ownerId == lastMessage?.deletedBy -> { + pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) + append(stringResource(R.string.title_messageDeletedByHost)) + pop() + } + else -> { + pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) + append(stringResource(R.string.title_messageWasDeleted)) + pop() + } + } + } else { + append(contents.localizedText) + } + } + + return messageBody to emptyMap() + } \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/list/RoomListScreen.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/list/RoomListScreen.kt new file mode 100644 index 000000000..f29485996 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/list/RoomListScreen.kt @@ -0,0 +1,208 @@ +package xyz.flipchat.app.features.chat.list + +import android.os.Parcelable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.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.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.paging.LoadState +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemContentType +import androidx.paging.compose.itemKey +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.navigator.currentOrThrow +import com.getcode.model.ID +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.extensions.getActivityScopedViewModel +import com.getcode.navigation.screens.AppScreen +import com.getcode.navigation.screens.NamedScreen +import com.getcode.theme.CodeTheme +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeCircularProgressIndicator +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.utils.addIf +import com.getcode.util.resources.LocalResources +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import xyz.flipchat.app.R +import xyz.flipchat.app.features.chat.openChatDirectiveBottomModal + +@Parcelize +class RoomListScreen : AppScreen(), NamedScreen, Parcelable { + + @IgnoredOnParcel + override val key: ScreenKey = uniqueScreenKey + + override val name: String + @Composable get() = stringResource(id = R.string.title_chat) + + @Composable + override fun Content() { + val navigator = LocalCodeNavigator.current + val viewModel = getActivityScopedViewModel() + ChatListScreenContent( + viewModel = viewModel, + openChat = { + navigator.push(ScreenRegistry.get(NavScreenProvider.Room.Messages(chatId = it))) + } + ) + + LaunchedEffect(result) { + result + .filter { it == true } + .onEach { viewModel.dispatchEvent(ChatListViewModel.Event.OnAccountCreated) } + .launchIn(this) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.show(ScreenRegistry.get(NavScreenProvider.CreateAccount.Start)) + }.launchIn(this) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.push(ScreenRegistry.get(NavScreenProvider.Room.Messages(it.roomId))) + }.launchIn(this) + } + } +} + +@Composable +private fun ChatListScreenContent( + viewModel: ChatListViewModel, + openChat: (ID) -> Unit, +) { + val navigator = LocalCodeNavigator.current + val resources = LocalResources.currentOrThrow + val state by viewModel.stateFlow.collectAsState() + val chats = viewModel.chats.collectAsLazyPagingItems() + val isLoading = chats.loadState.refresh is LoadState.Loading + var isInitialLoad by rememberSaveable { mutableStateOf(true) } + val listState = rememberLazyListState() + + CodeScaffold { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(bottom = CodeTheme.dimens.inset), + state = listState + ) { + items( + count = chats.itemCount, +// key = chats.itemKey { it.id }, + contentType = chats.itemContentType { "chat" } + ) { index -> + chats[index]?.let { + Column { + ChatNode( + chat = it, + onToggleMute = { mute -> + if (mute) { + viewModel.dispatchEvent(ChatListViewModel.Event.MuteRoom(it.id)) + } else { + viewModel.dispatchEvent(ChatListViewModel.Event.UnmuteRoom(it.id)) + } + }, + ) { openChat(it.conversation.id) } + } + } + } + + when { + isLoading && isInitialLoad -> { + item { + Column( + modifier = Modifier.fillParentMaxSize(), + horizontalAlignment = CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + CodeTheme.dimens.grid.x2, + CenterVertically + ), + ) { + CodeCircularProgressIndicator() + Text( + modifier = Modifier.fillMaxWidth(0.6f), + text = stringResource(R.string.subtitle_loadingChats), + textAlign = TextAlign.Center + ) + } + } + } + + else -> { + item { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(top = CodeTheme.dimens.grid.x6) + .padding(horizontal = CodeTheme.dimens.inset) + .addIf(!state.isLoggedIn) { Modifier.navigationBarsPadding() }, + buttonState = ButtonState.Filled, + text = stringResource(R.string.action_findRoom) + ) { + openChatDirectiveBottomModal( + resources = resources, + createCost = state.createRoomCost, + viewModel = viewModel, + navigator = navigator + ) + } + } + } + } + + // opts out of the list maintaining + // scroll position when adding elements before the first item + // we are checking first visible item index to ensure + // the list doesn't shift when scrolled + Snapshot.withoutReadObservation { + if (listState.firstVisibleItemIndex == 0) { + listState.requestScrollToItem( + index = listState.firstVisibleItemIndex, + scrollOffset = listState.firstVisibleItemScrollOffset + ) + } + } + } + } + + LaunchedEffect(chats.loadState.refresh) { + if (chats.loadState.refresh !is LoadState.Loading || chats.itemCount == 0) { + isInitialLoad = false + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/lookup/LookupRoomScreen.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/lookup/LookupRoomScreen.kt new file mode 100644 index 000000000..4b835d9f4 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/lookup/LookupRoomScreen.kt @@ -0,0 +1,119 @@ +package xyz.flipchat.app.features.chat.lookup + +import android.os.Parcelable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +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.stringResource +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.hilt.getViewModel +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.screens.NamedScreen +import xyz.flipchat.app.R +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import xyz.flipchat.app.ui.AmountWithKeypad + +@Parcelize +class LookupRoomScreen : Screen, NamedScreen, Parcelable { + + @IgnoredOnParcel + override val key: ScreenKey = uniqueScreenKey + + override val name: String + @Composable get() = stringResource(R.string.title_lookupRoom) + + @Composable + override fun Content() { + val viewModel = getViewModel() + val navigator = LocalCodeNavigator.current + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = name, + backButton = true, + onBackIconClicked = navigator::pop + ) + LookupRoomScreenContent(viewModel) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.roomId } + .onEach { + navigator.push(ScreenRegistry.get(NavScreenProvider.Room.Messages(it))) + }.launchIn(this) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.args } + .onEach { + navigator.push(ScreenRegistry.get(NavScreenProvider.Room.Info(it, returnToSender = true))) + }.launchIn(this) + } + } + + @Composable + private fun LookupRoomScreenContent( + viewModel: LookupRoomViewModel, + ) { + val state by viewModel.stateFlow.collectAsState() + + Column( + modifier = Modifier.fillMaxSize(), + ) { + AmountWithKeypad( + modifier = Modifier.weight(1f), + state.amountAnimatedModel, + prefix = "#", + hint = stringResource(R.string.subtitle_enterRoomNumber), + onNumberPressed = { viewModel.dispatchEvent(LookupRoomViewModel.Event.OnNumberPressed(it)) }, + onBackspace = { viewModel.dispatchEvent(LookupRoomViewModel.Event.OnBackspace) }, + ) + + Box(modifier = Modifier.fillMaxWidth()) { + CodeButton( + enabled = state.canLookup, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x2) + .navigationBarsPadding(), + buttonState = ButtonState.Filled, + text = stringResource(R.string.action_next), + isLoading = state.lookingUp, + isSuccess = state.success, + ) { + viewModel.dispatchEvent(LookupRoomViewModel.Event.OnLookupRoom) + } + } + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/lookup/LookupRoomViewModel.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/lookup/LookupRoomViewModel.kt new file mode 100644 index 000000000..fc2f7f3e3 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/lookup/LookupRoomViewModel.kt @@ -0,0 +1,160 @@ +package xyz.flipchat.app.features.chat.lookup + +import androidx.lifecycle.viewModelScope +import com.getcode.manager.TopBarManager +import com.getcode.model.ID +import com.getcode.navigation.RoomInfoArgs +import xyz.flipchat.app.R +import xyz.flipchat.app.features.login.register.onResult +import com.getcode.ui.components.text.AmountAnimatedInputUiModel +import com.getcode.ui.components.text.NumberInputHelper +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel2 +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import xyz.flipchat.controllers.ChatsController +import xyz.flipchat.services.extensions.titleOrFallback +import javax.inject.Inject + +@HiltViewModel +class LookupRoomViewModel @Inject constructor( + chatsController: ChatsController, + resources: ResourceHelper, +) : BaseViewModel2( + initialState = State(), + updateStateForEvent = updateStateForEvent +) { + private val numberInputHelper = NumberInputHelper() + + data class State( + val lookingUp: Boolean = false, + val success: Boolean = false, + val amountAnimatedModel: AmountAnimatedInputUiModel = AmountAnimatedInputUiModel( + amountData = NumberInputHelper.AmountAnimatedData("") + ), + val canLookup: Boolean = false, + ) + + sealed interface Event { + data class OnLookingUpRoom(val requesting: Boolean) : Event + data class OnNumberPressed(val number: Int) : Event + data object OnBackspace : Event + data class OnEnteredNumberChanged(val backspace: Boolean = false) : Event + data class OnRoomNumberChanged(val animatedInputUiModel: AmountAnimatedInputUiModel) : Event + data object OnLookupRoom : Event + data object OnRoomFound : Event + data class OnOpenConfirmation(val args: RoomInfoArgs) : Event + data class OpenExistingRoom(val roomId: ID) : Event + } + + init { + numberInputHelper.reset() + + eventFlow + .filterIsInstance() + .map { it.number } + .onEach { number -> + numberInputHelper.maxLength = 9 + numberInputHelper.onNumber(number) + dispatchEvent(Event.OnEnteredNumberChanged()) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + numberInputHelper.onBackspace() + dispatchEvent(Event.OnEnteredNumberChanged(true)) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.backspace } + .onEach { backspace -> + val current = stateFlow.value.amountAnimatedModel + val model = stateFlow.value.amountAnimatedModel + val amount = numberInputHelper.getFormattedStringForAnimation(includeCommas = false) + + val updated = model.copy( + amountDataLast = current.amountData, + amountData = amount, + lastPressedBackspace = backspace + ) + + dispatchEvent(Event.OnRoomNumberChanged(updated)) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { dispatchEvent(Event.OnLookingUpRoom(true)) } + .map { stateFlow.value.amountAnimatedModel.amountData.amount.toLong() } + .map { chatsController.lookupRoom(it) } + .onResult( + onError = { + dispatchEvent(Event.OnLookingUpRoom(false)) + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToGetRoom), + resources.getString( + R.string.error_description_failedToGetRoom, + stateFlow.value.amountAnimatedModel.amountData.amount + ) + ) + ) + }, + onSuccess = { + val isExistingMember = it.members.any { m -> m.isSelf } + if (isExistingMember) { + dispatchEvent(Event.OnLookingUpRoom(false)) + dispatchEvent(Event.OnRoomFound) + viewModelScope.launch { + delay(400) + dispatchEvent(Event.OpenExistingRoom(it.room.id)) + } + } else { + val host = it.members.firstOrNull { m -> m.isModerator } + + val confirmJoinArgs = RoomInfoArgs( + roomId = it.room.id, + roomTitle = it.room.titleOrFallback(resources), + roomNumber = it.room.roomNumber, + memberCount = it.members.count(), + ownerId = it.room.ownerId, + hostName = host?.identity?.displayName, + messagingFeeQuarks = it.room.messagingFee.quarks + ) + dispatchEvent(Event.OnOpenConfirmation(confirmJoinArgs)) + } + } + ).launchIn(viewModelScope) + } + + + companion object { + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + is Event.OnRoomNumberChanged -> { state -> + val room = event.animatedInputUiModel.amountData.amount.toIntOrNull() + state.copy( + amountAnimatedModel = event.animatedInputUiModel, + canLookup = (room ?: 0) > 0 + ) + } + + Event.OnBackspace, + is Event.OnEnteredNumberChanged, + Event.OnLookupRoom, + is Event.OnOpenConfirmation, + is Event.OpenExistingRoom, + is Event.OnNumberPressed -> { state -> state } + + is Event.OnRoomFound -> { state -> state.copy(success = true) } + is Event.OnLookingUpRoom -> { state -> state.copy(lookingUp = event.requesting) } + } + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/name/RoomNameScreen.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/name/RoomNameScreen.kt new file mode 100644 index 000000000..1e0a3fca3 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/name/RoomNameScreen.kt @@ -0,0 +1,200 @@ +package xyz.flipchat.app.features.chat.name + +import android.os.Parcelable +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.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.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.hilt.getViewModel +import com.getcode.model.ID +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.theme.CodeTheme +import com.getcode.theme.inputColors +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.TextInput +import com.getcode.ui.components.keyboardAsState +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.utils.ConstraintMode +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import xyz.flipchat.app.R +import kotlin.time.Duration.Companion.seconds + +@Parcelize +data class RoomNameScreen(val roomId: ID, val customTitle: String) : Screen, Parcelable { + + @IgnoredOnParcel + override val key: ScreenKey = uniqueScreenKey + + @Composable + override fun Content() { + val navigator = LocalCodeNavigator.current + val keyboardIsVisible by keyboardAsState() + val keyboard = LocalSoftwareKeyboardController.current + val scope = rememberCoroutineScope() + + val goBack = { + scope.launch { + if (keyboardIsVisible) { + keyboard?.hide() + delay(500) + } + navigator.pop() + } + } + BackHandler { + goBack() + } + + Column( + modifier = Modifier + .fillMaxSize() + ) { + AppBarWithTitle( + backButton = true, + title = stringResource(R.string.action_changeRoomName), + onBackIconClicked = { goBack() }, + ) + + val viewModel = getViewModel() + + LaunchedEffect(viewModel, roomId, customTitle) { + viewModel.dispatchEvent( + RoomNameScreenViewModel.Event.OnNewRequest( + roomId, + customTitle + ) + ) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + delay(2.seconds) + navigator.pop() + } + .launchIn(this) + } + + val state by viewModel.stateFlow.collectAsState() + RoomNameScreenContent(state, viewModel::dispatchEvent) + } + } +} + +@Composable +private fun RoomNameScreenContent( + state: RoomNameScreenViewModel.State, + dispatch: (RoomNameScreenViewModel.Event) -> Unit, +) { + val keyboard = LocalSoftwareKeyboardController.current + CodeScaffold( + modifier = Modifier + .fillMaxSize() + .imePadding(), + bottomBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(bottom = CodeTheme.dimens.grid.x3), + ) { + CodeButton( + enabled = state.canCheck || state.previousRoomName.isNotEmpty(), // allow resets + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset), + buttonState = ButtonState.Filled, + text = stringResource(R.string.action_save), + isLoading = state.update.loading, + isSuccess = state.update.success + ) { + keyboard?.hide() + dispatch(RoomNameScreenViewModel.Event.UpdateName) + } + } + } + ) { padding -> + + val focusRequester = remember { FocusRequester() } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterStart) + .padding(horizontal = CodeTheme.dimens.inset), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset) + ) { + TextInput( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + state = state.textFieldState, + colors = inputColors( + backgroundColor = Color.Transparent, + borderColor = Color.Transparent + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + capitalization = KeyboardCapitalization.Sentences + ), + maxLines = 1, + constraintMode = ConstraintMode.AutoSize(minimum = CodeTheme.typography.displaySmall), + style = CodeTheme.typography.displayMedium, + placeholderStyle = CodeTheme.typography.displayMedium, + placeholder = stringResource(R.string.subtitle_roomName), + ) + + Text( + text = stringResource(R.string.subtitle_roomNameHint), + style = CodeTheme.typography.textMedium, + color = Color.White.copy(0.4f) + ) + } + } + + LaunchedEffect(focusRequester) { + focusRequester.requestFocus() + } + } +} diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/name/RoomNameScreenViewModel.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/name/RoomNameScreenViewModel.kt new file mode 100644 index 000000000..f33990246 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/chat/name/RoomNameScreenViewModel.kt @@ -0,0 +1,127 @@ +package xyz.flipchat.app.features.chat.name + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.lifecycle.viewModelScope +import com.getcode.manager.TopBarManager +import com.getcode.model.ID +import com.getcode.model.NoId +import com.getcode.services.utils.onSuccessWithDelay +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel2 +import com.getcode.view.LoadingSuccessState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import xyz.flipchat.app.R +import xyz.flipchat.chat.RoomController +import xyz.flipchat.services.internal.network.service.SetRoomDisplayNameError +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +@HiltViewModel +internal class RoomNameScreenViewModel @Inject constructor( + roomController: RoomController, + resources: ResourceHelper, +) : BaseViewModel2( + initialState = State(), + updateStateForEvent = updateStateForEvent +) { + data class State( + val roomId: ID = NoId, + val previousRoomName: String = "", + val update: LoadingSuccessState = LoadingSuccessState(), + val textFieldState: TextFieldState = TextFieldState(" "), + ) { + val canCheck: Boolean + get() = textFieldState.text.isNotEmpty() + } + + sealed interface Event { + data class OnNewRequest(val id: ID, val title: String) : Event + data object UpdateName : Event + data object OnSuccess : Event + data class OnError(val reason: String) : Event + } + + init { + eventFlow + .filterIsInstance() + .map { stateFlow.value } + .onEach { + val textFieldState = it.textFieldState + val text = textFieldState.text.toString().trim() + + roomController.setDisplayName(it.roomId, text) + .onFailure { error -> + if (error is SetRoomDisplayNameError.CantSet) { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + title = resources.getString(R.string.error_title_failedToChangeRoomNameBecauseInappropriate), + message = resources.getString(R.string.error_description_failedToChangeRoomNameBecauseInappropriate) + ) + ) + } else { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + title = resources.getString(R.string.error_title_failedToChangeRoomNameOtherReason), + message = resources.getString(R.string.error_description_failedToChangeRoomNameOtherReason) + ) + ) + } + + dispatchEvent(Event.OnError("")) + } + .onSuccessWithDelay(2.seconds) { + dispatchEvent(Event.OnSuccess) + } + }.launchIn(viewModelScope) + } + + internal companion object { + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + is Event.OnError -> { state -> + state.copy( + update = LoadingSuccessState( + loading = false, + success = false + ) + ) + } + + Event.OnSuccess -> { state -> + state.copy( + update = LoadingSuccessState( + loading = false, + success = true + ) + ) + } + + Event.UpdateName -> { state -> + state.copy( + update = LoadingSuccessState( + loading = true, + success = false + ) + ) + } + + is Event.OnNewRequest -> { state -> + if (state.roomId != event.id) { + state.copy( + update = LoadingSuccessState(), + roomId = event.id, + textFieldState = TextFieldState(initialText = event.title), + previousRoomName = event.title, + ) + } else { + state + } + } + } + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/home/HomeViewModel.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/home/HomeViewModel.kt new file mode 100644 index 000000000..0b2a77124 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/home/HomeViewModel.kt @@ -0,0 +1,130 @@ +package xyz.flipchat.app.features.home + +import android.app.Activity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import xyz.flipchat.app.R +import xyz.flipchat.app.auth.AuthManager +import xyz.flipchat.app.util.Router +import xyz.flipchat.controllers.ChatsController +import xyz.flipchat.controllers.CodeController +import xyz.flipchat.controllers.ProfileController +import xyz.flipchat.services.user.AuthState +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel @Inject constructor( + private val authManager: AuthManager, + private val codeController: CodeController, + private val chatsController: ChatsController, + private val profileController: ProfileController, + private val userManager: UserManager, + val router: Router, + val resources: ResourceHelper, +) : ViewModel() { + + val isLoggedIn = userManager.state.map { it.authState }.filterIsInstance() + .map { true } + .stateIn(viewModelScope, started = SharingStarted.Eagerly, false) + + init { + userManager.state + .mapNotNull { it.authState } + .filterIsInstance() + .distinctUntilChanged() + .onEach { onAppOpen() } + .launchIn(viewModelScope) + } + + fun onAppOpen() { + getUpdatedUserFlags() + openStream() + requestAirdrop() + updateChats() + } + + private fun getUpdatedUserFlags() { + viewModelScope.launch { + profileController.getUserFlags() + } + } + + private fun requestAirdrop() { + if (userManager.authState is AuthState.LoggedIn) { + viewModelScope.launch { + codeController.fetchBalance() + .onFailure { it.printStackTrace() } + .onSuccess { codeController.requestAirdrop() } + } + } + } + + private fun updateChats() { + viewModelScope.launch { + if (userManager.authState.canOpenChatStream()) { + chatsController.updateRooms() + } + } + } + + fun openStream() { + if (userManager.authState.canOpenChatStream()) { + chatsController.openEventStream(viewModelScope) + } + } + + fun closeStream() { + chatsController.closeEventStream() + } + + fun handleLoginEntropy(entropy: String, onSwitchAccounts: () -> Unit, onCancel: () -> Unit) { + if (entropy != userManager.entropy) { + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = resources.getString(R.string.subtitle_logoutAndLoginConfirmation), + positiveText = resources.getString(R.string.action_logIn), + tertiaryText = resources.getString(R.string.action_cancel), + isDismissible = false, + onPositive = onSwitchAccounts, + onTertiary = onCancel, + ) + ) + } + } + + fun logout(activity: Activity, onComplete: () -> Unit) = viewModelScope.launch { + authManager.logout(activity) + .onSuccess { + chatsController.closeEventStream() + onComplete() + } + } + + fun deleteAccount(activity: Activity, onComplete: () -> Unit) = viewModelScope.launch { + authManager.deleteAndLogout(activity) + .onSuccess { + chatsController.closeEventStream() + onComplete() + } + } + + override fun onCleared() { + super.onCleared() + closeStream() + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/home/TabbedHomeScreen.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/home/TabbedHomeScreen.kt new file mode 100644 index 000000000..98bd73491 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/home/TabbedHomeScreen.kt @@ -0,0 +1,153 @@ +package xyz.flipchat.app.features.home + +import android.os.Parcelable +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.hilt.getViewModel +import cafe.adriel.voyager.navigator.tab.CurrentTab +import cafe.adriel.voyager.navigator.tab.TabDisposable +import cafe.adriel.voyager.navigator.tab.TabNavigator +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.extensions.getActivityScopedViewModel +import com.getcode.navigation.screens.ChildNavTab +import com.getcode.theme.CodeTheme +import com.getcode.theme.White +import com.getcode.ui.components.OnLifecycleEvent +import com.getcode.ui.utils.getActivity +import com.getcode.ui.utils.withTopBorder +import dev.theolm.rinku.DeepLink +import dev.theolm.rinku.compose.ext.DeepLinkListener +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue +import xyz.flipchat.app.features.settings.SettingsViewModel +import xyz.flipchat.app.util.DeeplinkType +import kotlin.math.log + +@Parcelize +class TabbedHomeScreen(private val deepLink: @RawValue DeepLink?) : Screen, Parcelable { + @IgnoredOnParcel + override val key: ScreenKey = uniqueScreenKey + + @Composable + override fun Content() { + val viewModel = getActivityScopedViewModel() + val router = viewModel.router + + val isLoggedIn by viewModel.isLoggedIn.collectAsState() + + val initialTab = remember(deepLink) { router.getInitialTabIndex(deepLink) } + + OnLifecycleEvent { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + router.checkTabs() + } + else -> Unit + } + } + + TabNavigator( + tab = viewModel.router.rootTabs[initialTab], + tabDisposable = { + TabDisposable( + navigator = it, + tabs = router.rootTabs, + ) + } + ) { tabNavigator -> + Column( + modifier = Modifier + .statusBarsPadding() + .background(CodeTheme.colors.background) + ) { + Box( + modifier = Modifier.weight(1f) + ) { + CurrentTab() + } + if (isLoggedIn) { + Row( + modifier = Modifier + .fillMaxWidth() + .withTopBorder() + ) { + router.rootTabs.fastForEach { tab -> + val backgroundColor by animateColorAsState( + if (tabNavigator.current.options.index == tab.options.index) CodeTheme.colors.brandSubtle else CodeTheme.colors.surface, + label = "selected tab color" + ) + Box( + modifier = Modifier + .background(backgroundColor) + .weight(1f) + .clickable { tabNavigator.current = tab } + .navigationBarsPadding(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .padding( + top = CodeTheme.dimens.grid.x2, + bottom = 0.dp + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + CodeTheme.dimens.grid.x1, + Alignment.CenterVertically + ) + ) { + Image( + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), + painter = tab.options.icon!!, + contentDescription = null, + ) + + Text( + text = tab.options.title, + style = CodeTheme.typography.textSmall, + color = White + ) + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/home/tabs/CashTab.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/home/tabs/CashTab.kt new file mode 100644 index 000000000..e8860fdba --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/home/tabs/CashTab.kt @@ -0,0 +1,99 @@ +package xyz.flipchat.app.features.home.tabs + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.hilt.getViewModel +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.tab.TabOptions +import com.getcode.manager.BottomBarManager +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.screens.ChildNavTab +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.utils.addIf +import com.getcode.ui.utils.getActivity +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import xyz.flipchat.app.R +import xyz.flipchat.app.features.chat.openChatDirectiveBottomModal +import xyz.flipchat.app.features.home.HomeViewModel +import xyz.flipchat.app.features.settings.SettingsViewModel + +internal object CashTab : ChildNavTab { + override val key: ScreenKey = uniqueScreenKey + + override val ordinal: Int = 1 + + override val options: TabOptions + @Composable get() = TabOptions( + index = ordinal.toUShort(), + title = stringResource(R.string.title_cashTab), + icon = painterResource(R.drawable.ic_fc_balance) + ) + + @Composable + override fun Content() { + val viewModel = getViewModel() + val context = LocalContext.current + val composeScope = rememberCoroutineScope() + val navigator = LocalCodeNavigator.current + Column { + AppBarWithTitle( + title = options.title, + ) + CodeScaffold( + bottomBar = { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x3), + buttonState = ButtonState.Subtle, + text = stringResource(R.string.action_deleteMyAccount) + ) { + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = context.getString(R.string.prompt_title_deleteAccount), + subtitle = context + .getString(R.string.prompt_description_deleteAccount), + positiveText = context.getString(R.string.action_permanentlyDeleteAccount), + tertiaryText = context.getString(R.string.action_cancel), + onPositive = { + composeScope.launch { + delay(150) + context.getActivity()?.let { + viewModel.deleteAccount(it) { + navigator.replaceAll(ScreenRegistry.get(NavScreenProvider.Login.Home())) + } + } + } + } + ) + ) + } + }, + ) { padding -> + Box(modifier = Modifier.padding(padding)) { + Navigator(ScreenRegistry.get(NavScreenProvider.Balance)) + } + } + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/home/tabs/ChatTab.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/home/tabs/ChatTab.kt new file mode 100644 index 000000000..bd158ceca --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/home/tabs/ChatTab.kt @@ -0,0 +1,178 @@ +package xyz.flipchat.app.features.home.tabs + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.hilt.getViewModel +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.currentOrThrow +import cafe.adriel.voyager.navigator.tab.TabOptions +import com.getcode.manager.BottomBarManager +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.extensions.getActivityScopedViewModel +import com.getcode.navigation.screens.ChildNavTab +import com.getcode.theme.Black40 +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeCircularProgressIndicator +import com.getcode.ui.utils.addIf +import com.getcode.ui.utils.getActivity +import com.getcode.ui.utils.noRippleClickable +import com.getcode.ui.utils.rememberedClickable +import com.getcode.ui.utils.unboundedClickable +import com.getcode.util.resources.LocalResources +import kotlinx.parcelize.IgnoredOnParcel +import xyz.flipchat.app.R +import xyz.flipchat.app.features.chat.list.ChatListViewModel +import xyz.flipchat.app.features.chat.openChatDirectiveBottomModal +import xyz.flipchat.app.features.home.tabs.ChatTab.options +import xyz.flipchat.app.features.settings.SettingsViewModel + +internal object ChatTab : ChildNavTab { + + @IgnoredOnParcel + override val key: ScreenKey = uniqueScreenKey + + override val ordinal: Int = 0 + + override val options: TabOptions + @Composable get() = TabOptions( + index = ordinal.toUShort(), + title = stringResource(R.string.title_chatsTab), + icon = painterResource(R.drawable.ic_fc_chats) + ) + + @Composable + override fun Content() { + val navigator = LocalCodeNavigator.current + val context = LocalContext.current + val resources = LocalResources.currentOrThrow + val viewModel = getActivityScopedViewModel() + val settingsVm = getViewModel() + val state by viewModel.stateFlow.collectAsState() + + Box { + Column { + AppBarWithTitle( + title = { + LogOutTitle( + state = state, + onTitleClicked = { + viewModel.dispatchEvent(ChatListViewModel.Event.OnChatsTapped) + }, + onLogout = { + context.getActivity()?.let { + settingsVm.logout(it) { + navigator.replaceAll(ScreenRegistry.get(NavScreenProvider.Login.Home())) + } + } + } + ) + }, + rightContents = { + Image( + modifier = Modifier + .background(color = CodeTheme.colors.tertiary, shape = CircleShape) + .padding(CodeTheme.dimens.grid.x1) + .unboundedClickable { + openChatDirectiveBottomModal( + resources = resources, + createCost = state.createRoomCost, + viewModel = viewModel, + navigator = navigator + ) + }, + imageVector = Icons.Default.Add, + contentDescription = null, + colorFilter = ColorFilter.tint(CodeTheme.colors.onBackground) + ) + } + ) + Navigator(ScreenRegistry.get(NavScreenProvider.Room.List)) + } + + val scrimAlpha by animateFloatAsState( + if (state.showScrim) 1f else 0f, + label = "scrim visibility" + ) + + if (state.showScrim) { + Box( + modifier = Modifier + .fillMaxSize() + .alpha(scrimAlpha) + .background(Black40) + .rememberedClickable(indication = null, + interactionSource = remember { MutableInteractionSource() }) {} + ) { + if (state.showFullscreenSpinner) { + CodeCircularProgressIndicator( + modifier = Modifier + .size(50.dp) + .align(Alignment.Center) + ) + } + } + } + } + } +} + +@Composable +private fun LogOutTitle( + modifier: Modifier = Modifier, + state: ChatListViewModel.State, + onTitleClicked: () -> Unit, + onLogout: () -> Unit +) { + val context = LocalContext.current + AppBarDefaults.Title( + modifier = modifier + .addIf(!state.isLogOutEnabled) { + Modifier.noRippleClickable { + onTitleClicked() + } + } + .addIf(state.isLogOutEnabled) { + Modifier.unboundedClickable { + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = context.getString(R.string.prompt_title_logout), + subtitle = context + .getString(R.string.prompt_description_logout), + positiveText = context.getString(R.string.action_logout), + tertiaryText = context.getString(R.string.action_cancel), + onPositive = onLogout + ) + ) + } + }, + text = options.title + ) +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/home/tabs/SettingsTab.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/home/tabs/SettingsTab.kt new file mode 100644 index 000000000..539771480 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/home/tabs/SettingsTab.kt @@ -0,0 +1,28 @@ +package xyz.flipchat.app.features.home.tabs + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.tab.TabOptions +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.screens.ChildNavTab +import xyz.flipchat.app.R + +internal object SettingsTab : ChildNavTab { + + override val ordinal: Int = 2 + + override val options: TabOptions + @Composable get() = TabOptions( + index = ordinal.toUShort(), + title = stringResource(R.string.title_settingsTab), + icon = painterResource(R.drawable.ic_settings_outline) + ) + + @Composable + override fun Content() { + Navigator(ScreenRegistry.get(NavScreenProvider.Settings)) + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/LoginHome.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/LoginHome.kt new file mode 100644 index 000000000..17db4b0fb --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/LoginHome.kt @@ -0,0 +1,166 @@ +package xyz.flipchat.app.features.login + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Science +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +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.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import com.getcode.theme.CodeTheme +import com.getcode.theme.White +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.utils.noRippleClickable +import com.getcode.view.LoadingSuccessState +import xyz.flipchat.app.R +import xyz.flipchat.app.util.ChromeTabsUtils + +@Composable +fun LoginHome( + isSpectatorJoinEnabled: Boolean = false, + isCreatingAccount: LoadingSuccessState = LoadingSuccessState(), + betaFlagsVisible: Boolean = false, + onLogoTapped: () -> Unit, + openBetaFlags: () -> Unit, + createAccount: () -> Unit, + login: () -> Unit, +) { + val context = LocalContext.current + + Box( + modifier = Modifier + .fillMaxSize() + .background(CodeTheme.colors.secondary) + .windowInsetsPadding(WindowInsets.navigationBars), + ) { + Column(modifier = Modifier.fillMaxSize()) { + Spacer(Modifier.weight(1f)) + + Column( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .fillMaxWidth(0.65f) + .noRippleClickable(enabled = !betaFlagsVisible) { onLogoTapped() }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset) + ) { + Image( + painter = painterResource(R.drawable.flipchat_logo), + contentDescription = "", + modifier = Modifier + ) + Text( + text = stringResource(R.string.app_name_without_variant), + style = CodeTheme.typography.displayMedium, + color = White + ) + } + + Spacer(Modifier.weight(1f)) + + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset), + onClick = createAccount, + isLoading = isCreatingAccount.loading, + isSuccess = isCreatingAccount.success, + text = if (isSpectatorJoinEnabled) { + stringResource(R.string.action_getStarted) + } else { + stringResource(R.string.action_createAccount) + }, + buttonState = ButtonState.Filled, + ) + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset), + onClick = login, + text = stringResource(R.string.action_logIn), + buttonState = ButtonState.Subtle, + ) + + + val bottomString = buildAnnotatedString { + append(stringResource(R.string.login_description_byTapping)) + append(" ") + append(stringResource(R.string.login_description_agreeToOur)) + append(" ") + pushStringAnnotation(tag = "tos", annotation = stringResource(R.string.app_tos)) + withStyle(style = SpanStyle(textDecoration = TextDecoration.Underline)) { + append(stringResource(R.string.title_termsOfService)) + } + pop() + append(" ") + append(stringResource(R.string.core_and)) + append(" ") + pushStringAnnotation( + tag = "policy", + annotation = stringResource(R.string.app_privacy_policy) + ) + withStyle(style = SpanStyle(textDecoration = TextDecoration.Underline)) { + append(stringResource(R.string.title_privacyPolicy)) + } + pop() + } + + ClickableText( + text = bottomString, + style = CodeTheme.typography.caption.copy( + textAlign = TextAlign.Center, + color = CodeTheme.colors.textSecondary + ), + modifier = Modifier + .padding(CodeTheme.dimens.grid.x4), + onClick = { offset -> + bottomString.getStringAnnotations(tag = "tos", start = offset, end = offset) + .firstOrNull()?.let { + ChromeTabsUtils.launchUrl(context, it.item) + } + bottomString.getStringAnnotations(tag = "policy", start = offset, end = offset) + .firstOrNull()?.let { + ChromeTabsUtils.launchUrl(context, it.item) + } + } + ) + } + + if (betaFlagsVisible) { + IconButton( + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(top = CodeTheme.dimens.inset), + onClick = openBetaFlags + ) { + Icon(Icons.Filled.Science, contentDescription = null, tint = Color.White) + } + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/LoginScreen.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/LoginScreen.kt new file mode 100644 index 000000000..5b42037b2 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/LoginScreen.kt @@ -0,0 +1,66 @@ +package xyz.flipchat.app.features.login + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.hilt.getViewModel +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.core.LocalCodeNavigator +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import kotlin.time.Duration.Companion.seconds + +@Parcelize +data class LoginScreen(val seed: String? = null) : Screen, Parcelable { + @IgnoredOnParcel + override val key: ScreenKey = uniqueScreenKey + + @Composable + override fun Content() { + val vm = getViewModel() + val state by vm.stateFlow.collectAsState() + val navigator = LocalCodeNavigator.current + + LaunchedEffect(vm) { + vm.eventFlow + .filterIsInstance() + .onEach { delay(2.seconds) } + .onEach { navigator.replaceAll(ScreenRegistry.get(NavScreenProvider.AppHomeScreen())) } + .launchIn(this) + } + + if (seed != null) { +// SeedDeepLink(getViewModel(), seed) + } else { + LoginHome( + isCreatingAccount = state.creatingAccount, + betaFlagsVisible = state.betaOptionsVisible, + isSpectatorJoinEnabled = state.followerModeEnabled, + onLogoTapped = { vm.dispatchEvent(LoginViewModel.Event.OnLogoTapped) }, + openBetaFlags = { + navigator.push(ScreenRegistry.get(NavScreenProvider.BetaFlags)) + }, + createAccount = { + if (state.followerModeEnabled) { + vm.dispatchEvent(LoginViewModel.Event.CreateAccount) + } else { + navigator.push(ScreenRegistry.get(NavScreenProvider.CreateAccount.NameEntry())) + } + }, + login = { + navigator.push(ScreenRegistry.get(NavScreenProvider.Login.SeedInput)) + } + ) + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/LoginViewModel.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/LoginViewModel.kt new file mode 100644 index 000000000..ad1e45e6a --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/LoginViewModel.kt @@ -0,0 +1,93 @@ +package xyz.flipchat.app.features.login + +import androidx.lifecycle.viewModelScope +import com.getcode.manager.TopBarManager +import com.getcode.services.utils.onSuccessWithDelay +import com.getcode.view.BaseViewModel2 +import com.getcode.view.LoadingSuccessState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import xyz.flipchat.app.auth.AuthManager +import xyz.flipchat.app.beta.Lab +import xyz.flipchat.app.beta.Labs +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +@HiltViewModel +class LoginViewModel @Inject constructor( + private val authManager: AuthManager, + betaFlags: Labs, +) : BaseViewModel2( + initialState = State(), + updateStateForEvent = updateStateForEvent +) { + data class State( + val followerModeEnabled: Boolean = false, + val creatingAccount: LoadingSuccessState = LoadingSuccessState(), + val logoTapCount: Int = 0, + val betaOptionsVisible: Boolean = false, + ) + + sealed interface Event { + data object OnLogoTapped: Event + data object BetaOptionsUnlocked: Event + data class OnFollowerModeEnabled(val enabled: Boolean): Event + data object CreateAccount: Event + data object OnAccountCreated: Event + data object CreateFailed: Event + } + + init { + betaFlags.observe(Lab.FollowerMode) + .onEach { dispatchEvent(Event.OnFollowerModeEnabled(it)) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { stateFlow.value.logoTapCount } + .filter { it >= TAP_THRESHOLD } + .filterNot { stateFlow.value.betaOptionsVisible } + .onEach { dispatchEvent(Event.BetaOptionsUnlocked) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { + authManager.createAccount() + .onFailure { + dispatchEvent(Event.CreateFailed) + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + title = "Create Account Failed", + message = it.message ?: "Something went wrong" + ) + ) + }.onSuccessWithDelay(2.seconds) { + dispatchEvent(Event.OnAccountCreated) + } + } + .launchIn(viewModelScope) + } + + internal companion object { + private const val TAP_THRESHOLD = 6 + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + Event.CreateAccount -> { state -> state.copy(creatingAccount = LoadingSuccessState(loading = true)) } + Event.OnAccountCreated -> { state -> state.copy(creatingAccount = LoadingSuccessState(loading = false, success = true)) } + Event.CreateFailed -> { state -> state.copy(creatingAccount = LoadingSuccessState(loading = false)) } + is Event.OnFollowerModeEnabled -> { state -> state.copy(followerModeEnabled = event.enabled) } + is Event.BetaOptionsUnlocked -> { state -> state.copy(betaOptionsVisible = true) } + is Event.OnLogoTapped -> { state -> + if (state.logoTapCount >= TAP_THRESHOLD) state + else state.copy(logoTapCount = state.logoTapCount + 1) + } + } + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/accesskey/AccessKeyScreen.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/accesskey/AccessKeyScreen.kt new file mode 100644 index 000000000..f22a45b5c --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/accesskey/AccessKeyScreen.kt @@ -0,0 +1,306 @@ +package xyz.flipchat.app.features.login.accesskey + +import android.Manifest +import android.os.Build +import android.os.Parcelable +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material.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.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.draw.alpha +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.hilt.getViewModel +import com.getcode.manager.BottomBarManager +import com.getcode.manager.TopBarManager +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.screens.NamedScreen +import com.getcode.theme.CodeTheme +import com.getcode.theme.White +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.Cloudy +import com.getcode.ui.components.SelectionContainer +import com.getcode.ui.components.rememberSelectionState +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.utils.addIf +import com.getcode.ui.utils.measured +import com.getcode.util.permissions.PermissionResult +import com.getcode.util.permissions.getPermissionLauncher +import com.getcode.util.permissions.rememberPermissionHandler +import kotlinx.coroutines.delay +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import xyz.flipchat.app.R +import xyz.flipchat.app.util.launchAppSettings + +@Parcelize +class AccessKeyScreen: Screen, NamedScreen, Parcelable { + + @IgnoredOnParcel + override val key: ScreenKey = uniqueScreenKey + + override val name: String + @Composable get() = stringResource(R.string.title_accessKey) + + @Composable + override fun Content() { + val viewModel = getViewModel() + val navigator = LocalCodeNavigator.current + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = name, + ) + AccessKeyScreenContent(viewModel) { + navigator.push(ScreenRegistry.get(NavScreenProvider.Login.NotificationPermission(true))) + } + } + + BackHandler { /* intercept */ } + } +} + + +@Composable +internal fun AccessKeyScreenContent(viewModel: LoginAccessKeyViewModel, onCompleted: () -> Unit) { + val navigator = LocalCodeNavigator.current + val context = LocalContext.current + val dataState by viewModel.uiFlow.collectAsState() + + var isExportSeedRequested by remember { mutableStateOf(false) } + var isStoragePermissionGranted by remember { mutableStateOf(false) } + val isAccessKeyVisible = remember { MutableTransitionState(false) } + + val onPermissionResult = { result: PermissionResult -> + isStoragePermissionGranted = result == PermissionResult.Granted + + if (!isStoragePermissionGranted) { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + title = context.getString(R.string.error_title_failedToSave), + message = context.getString(R.string.error_description_failedToSave), + type = TopBarManager.TopBarMessageType.ERROR, + secondaryText = context.getString(R.string.action_openSettings), + secondaryAction = { context.launchAppSettings() } + ) + ) + } + } + + val launcher = getPermissionLauncher(Manifest.permission.WRITE_EXTERNAL_STORAGE, onPermissionResult) + val permissionChecker = rememberPermissionHandler() + + LaunchedEffect(isExportSeedRequested, isStoragePermissionGranted) { + if (isExportSeedRequested && isStoragePermissionGranted) { + viewModel.saveImage() + .onSuccess { + delay(400) + onCompleted() + } + .onFailure { + isExportSeedRequested = false + } + } + } + + val onExportClick = { + isExportSeedRequested = true + + if (Build.VERSION.SDK_INT > 29) { + isStoragePermissionGranted = true + } else { + permissionChecker.request( + permission = Manifest.permission.WRITE_EXTERNAL_STORAGE, + onPermissionResult = onPermissionResult, + launcher = launcher + ) + } + + } + val onSkipClick = { + onCompleted() + } + + var buttonHeight by remember { + mutableStateOf(0.dp) + } + + val selectionState = rememberSelectionState( + content = dataState.words.joinToString(" ") + ) + + SelectionContainer( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.navigationBars), + state = selectionState, + ) { + Cloudy( + modifier = Modifier + .fillMaxSize(), + enabled = selectionState.shown + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(vertical = CodeTheme.dimens.grid.x4) + ) { + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .measured { buttonHeight = it.height }, + ) { + CodeButton( + modifier = Modifier.fillMaxWidth(), + onClick = onExportClick, + text = stringResource(R.string.action_saveAccessKey), + buttonState = ButtonState.Filled, + isLoading = dataState.isLoading, + enabled = dataState.isEnabled, + isSuccess = dataState.isSuccess, + ) + + CodeButton( + modifier = Modifier.fillMaxWidth(), + onClick = { + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = context.getString(R.string.prompt_title_wroteThemDown), + subtitle = context + .getString(R.string.prompt_description_wroteThemDown), + positiveText = context + .getString(R.string.action_yesWroteThemDown), + negativeText = "", + tertiaryText = context.getString(R.string.action_cancel), + onPositive = { onSkipClick() }, + onNegative = {} + ) + ) + }, + text = stringResource(R.string.action_wroteThemDownInstead), + buttonState = ButtonState.Subtle, + enabled = dataState.isEnabled, + ) + } + } + } + + Column( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxHeight() + .addIf(buttonHeight.isSpecified) { Modifier.padding(bottom = buttonHeight + CodeTheme.dimens.grid.x4) }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + modifier = Modifier + // highly specific aspect ratio from iOS :) + .aspectRatio(0.607f, matchHeightConstraintsFirst = true) + .fillMaxWidth() + .weight(1f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + AnimatedVisibility( + visibleState = isAccessKeyVisible, + enter = fadeIn(animationSpec = tween(300, 0)), + exit = fadeOut(animationSpec = tween(300, 0)) + ) { + dataState.accessKeyCroppedBitmap?.let { bitmap -> + Image( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .scale(selectionState.scale.value), + bitmap = bitmap.asImageBitmap(), + contentScale = ContentScale.Crop, + contentDescription = dataState.wordsFormatted, + ) + } + } + } + + val textAlpha by animateFloatAsState( + if (selectionState.shown) 0f else 1f, + label = "text alpha" + ) + + Text( + modifier = Modifier + .alpha(textAlpha) + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.grid.x5) + .padding( + top = CodeTheme.dimens.grid.x3, + bottom = CodeTheme.dimens.grid.x6 + ), + style = CodeTheme.typography.textSmall.copy(textAlign = TextAlign.Center), + color = White, + text = stringResource(R.string.subtitle_accessKeyDescription) + ) + } + + + BackHandler { + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = context.getString(R.string.prompt_title_exitAccountCreation), + subtitle = context + .getString(R.string.prompt_description_exitAccountCreation), + positiveText = context.getString(R.string.action_exit), + negativeText = "", + tertiaryText = context.getString(R.string.action_cancel), + onPositive = { navigator.replaceAll(ScreenRegistry.get(NavScreenProvider.Login.Home())) }, + type = BottomBarManager.BottomBarMessageType.DESTRUCTIVE, + onNegative = {} + ) + ) + } + + LaunchedEffect(dataState.accessKeyCroppedBitmap) { + isAccessKeyVisible.targetState = dataState.accessKeyCroppedBitmap != null + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/accesskey/LoginAccessKeyViewModel.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/accesskey/LoginAccessKeyViewModel.kt new file mode 100644 index 000000000..43e621cf9 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/accesskey/LoginAccessKeyViewModel.kt @@ -0,0 +1,22 @@ +package xyz.flipchat.app.features.login.accesskey + +import com.getcode.libs.qr.QRCodeGenerator +import com.getcode.services.manager.MnemonicManager +import com.getcode.util.resources.ResourceHelper +import dagger.hilt.android.lifecycle.HiltViewModel +import xyz.flipchat.app.features.accesskey.BaseAccessKeyViewModel +import xyz.flipchat.app.util.media.MediaScanner +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject + +@HiltViewModel +class LoginAccessKeyViewModel @Inject constructor( + resources: ResourceHelper, + mnemonicManager: MnemonicManager, + mediaScanner: MediaScanner, + userManager: UserManager, + qrCodeGenerator: QRCodeGenerator +): BaseAccessKeyViewModel(resources, mnemonicManager, mediaScanner, userManager, qrCodeGenerator) { + + suspend fun saveImage(): Result = saveBitmapToFile().map { Unit } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/accesskey/SeedInputScreen.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/accesskey/SeedInputScreen.kt new file mode 100644 index 000000000..79abbc0f9 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/accesskey/SeedInputScreen.kt @@ -0,0 +1,189 @@ +package xyz.flipchat.app.features.login.accesskey + +import android.os.Parcelable +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.constraintlayout.compose.ConstraintLayout +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.hilt.getViewModel +import com.getcode.navigation.core.CodeNavigator +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.screens.NamedScreen +import xyz.flipchat.app.R +import com.getcode.theme.CodeTheme +import com.getcode.theme.inputColors +import com.getcode.theme.topBarHeight +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.util.permissions.notificationPermissionCheck +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize + +@Parcelize +data object SeedInputScreen: Screen, NamedScreen, Parcelable { + + @IgnoredOnParcel + override val key: ScreenKey = uniqueScreenKey + + override val name: String + @Composable get() = stringResource(R.string.title_enterAccessKeyWords) + + @Composable + override fun Content() { + val viewModel: SeedInputViewModel = getViewModel() + val navigator = LocalCodeNavigator.current + Column { + AppBarWithTitle( + backButton = true, + onBackIconClicked = { navigator.pop() }, + title = name, + ) + SeedInput(viewModel) + } + } +} + +@Composable +private fun SeedInput(viewModel: SeedInputViewModel) { + val navigator: CodeNavigator = LocalCodeNavigator.current + val dataState by viewModel.uiFlow.collectAsState() + val focusManager = LocalFocusManager.current + val focusRequester = FocusRequester() + + val notificationPermissionCheck = notificationPermissionCheck(isShowError = false) { } + + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.systemBars) + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = topBarHeight) + .padding(bottom = CodeTheme.dimens.grid.x4) + .verticalScroll(rememberScrollState()) + .imePadding(), + ) { + ConstraintLayout( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + ) { + val (input, wordCount, checkboxValid, captionText) = createRefs() + + Text( + modifier = Modifier + .constrainAs(captionText) { + top.linkTo(parent.top) + }, + style = CodeTheme.typography.textSmall.copy(textAlign = TextAlign.Center), + color = CodeTheme.colors.textSecondary, + text = stringResource(R.string.subtitle_loginDescription) + ) + + OutlinedTextField( + modifier = Modifier + .constrainAs(input) { + top.linkTo(captionText.bottom) + } + .padding(top = CodeTheme.dimens.inset) + .fillMaxWidth() + .height(120.dp) + .focusRequester(focusRequester), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + visualTransformation = VisualTransformation.None, + value = dataState.wordsString, + onValueChange = { viewModel.onTextChange(it) }, + textStyle = CodeTheme.typography.textLarge.copy( + fontSize = 16.sp, + ), + colors = inputColors(), + ) + + Text( + text = dataState.wordCount.toString(), + color = CodeTheme.colors.textSecondary, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .constrainAs(wordCount) { + bottom.linkTo(input.bottom) + } + .padding(bottom = CodeTheme.dimens.grid.x2, start = CodeTheme.dimens.grid.x2) + ) + + if (dataState.isValid) { + Image( + painter = painterResource(id = R.drawable.ic_checked_blue), + modifier = Modifier + .constrainAs(checkboxValid) { + start.linkTo(wordCount.end) + top.linkTo(wordCount.top) + bottom.linkTo(wordCount.bottom) + } + .padding(bottom = CodeTheme.dimens.grid.x2, start = CodeTheme.dimens.grid.x1) + .height(CodeTheme.dimens.grid.x3), + contentDescription = "" + ) + } + } + + if (dataState.isSuccess) { + notificationPermissionCheck(true) + } + + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding( + top = CodeTheme.dimens.grid.x3, + bottom = CodeTheme.dimens.grid.x4 + ), + onClick = { + focusManager.clearFocus() + viewModel.onSubmit(navigator) + }, + isLoading = dataState.isLoading, + isSuccess = dataState.isSuccess, + enabled = dataState.continueEnabled, + text = stringResource(R.string.action_logIn), + buttonState = ButtonState.Filled, + ) + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/accesskey/SeedInputViewModel.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/accesskey/SeedInputViewModel.kt new file mode 100644 index 000000000..d308c30d9 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/accesskey/SeedInputViewModel.kt @@ -0,0 +1,160 @@ +package xyz.flipchat.app.features.login.accesskey + +import android.annotation.SuppressLint +import android.app.Activity +import androidx.lifecycle.viewModelScope +import cafe.adriel.voyager.core.registry.ScreenRegistry +import com.getcode.crypt.MnemonicPhrase +import com.getcode.manager.BottomBarManager +import com.getcode.manager.TopBarManager +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.core.CodeNavigator +import xyz.flipchat.app.R +import xyz.flipchat.app.util.AccountManager +import com.getcode.services.analytics.AnalyticsService +import com.getcode.services.manager.MnemonicManager +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.ErrorUtils +import com.getcode.view.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import xyz.flipchat.app.auth.AuthManager +import java.util.Locale +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +data class SeedInputUiModel( + val wordsString: String = "", + val wordCount: Int = 0, + val continueEnabled: Boolean = false, + val isValid: Boolean = false, + val isLoading: Boolean = false, + val isSuccess: Boolean = false, +) + +@HiltViewModel +class SeedInputViewModel @Inject constructor( + private val analyticsService: AnalyticsService, + private val authManager: AuthManager, + private val resources: ResourceHelper, + private val mnemonicManager: MnemonicManager, + private val accountManager: AccountManager, +) : BaseViewModel(resources) { + val uiFlow = MutableStateFlow(SeedInputUiModel()) + private val mnemonicCode = mnemonicManager.mnemonicCode + + init { + viewModelScope.launch { + val token = accountManager.getToken() + if (token != null) { + analyticsService.unintentionalLogout() + ErrorUtils.handleError( + Throwable("We shouldn't be here. Login screen visible with associated account in AccountManager.") + ) + } + } + } + + fun onTextChange(wordsString: String) { + val isLoading = uiFlow.value.isLoading + val isSuccess = uiFlow.value.isSuccess + if (isLoading || isSuccess) return + + val userWordList = wordsString.lowercase(Locale.CANADA).split(" ") + val wordCount = getValidCount(userWordList, mnemonicCode.wordList) + uiFlow.update { + it.copy( + wordsString = wordsString, + wordCount = wordCount, + continueEnabled = wordCount == 12, + isValid = wordCount == 12 + ) + } + } + + fun onSubmit(navigator: CodeNavigator) { + val userWordList = + uiFlow.value.wordsString.trim().replace(Regex("(\\s)+"), " ").lowercase(Locale.getDefault()).split(" ") + val mnemonic = MnemonicPhrase.newInstance(userWordList) ?: return + + + CoroutineScope(Dispatchers.IO).launch { + val entropyB64: String + try { + entropyB64 = mnemonicManager.getEncodedBase64(mnemonic) + } catch (e: Exception) { + showError(navigator) + return@launch + } + + performLogin(navigator, entropyB64) + } + } + + @SuppressLint("CheckResult") + fun performLogin(navigator: CodeNavigator, entropyB64: String, deeplink: Boolean = false) { + viewModelScope.launch { + setState(isLoading = true, isSuccess = false, isContinueEnabled = false) + authManager.login(entropyB64) + .onFailure { + if (it is AuthManager.AuthManagerException.TimelockUnlockedException) { + TopBarManager.showMessage( + getString(R.string.error_title_timelockUnlocked), + getString(R.string.error_description_timelockUnlocked) + ) + navigator.popAll() + } else { + showError(navigator) + } + setState(isLoading = false, isSuccess = false, isContinueEnabled = true) + } + .onSuccess { + setState(isLoading = false, isSuccess = true, isContinueEnabled = false) + delay(if (deeplink) 0.seconds else 1.seconds) + navigator.replaceAll(ScreenRegistry.get(NavScreenProvider.AppHomeScreen())) + } + } + } + + private fun setState(isLoading: Boolean, isSuccess: Boolean, isContinueEnabled: Boolean) { + uiFlow.update { + it.copy( + isLoading = isLoading, + isSuccess = isSuccess, + continueEnabled = isContinueEnabled + ) + } + } + + override fun setIsLoading(isLoading: Boolean) { + uiFlow.update { + it.copy( + isLoading = isLoading, + continueEnabled = false + ) + } + } + + private fun getValidCount(userWordList: List, mnemonicWordList: List): Int { + return userWordList.filter { it in mnemonicWordList }.size + } + + private fun showError(navigator: CodeNavigator) { + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = resources.getString(R.string.prompt_title_notFlipchatAccount), + subtitle = resources.getString(R.string.prompt_description_notFlipchatAccount), + positiveText = resources.getString(R.string.action_createNewFlipchatAccount), + negativeText = resources.getString(R.string.action_tryDifferentFlipchatAccount), + onPositive = { + navigator.replaceAll(ScreenRegistry.get(NavScreenProvider.Login.Home())) + } + ) + ) + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/permissions/NotificationPermission.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/permissions/NotificationPermission.kt new file mode 100644 index 000000000..f386df5fa --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/permissions/NotificationPermission.kt @@ -0,0 +1,131 @@ +package xyz.flipchat.app.features.login.permissions + +import android.os.Parcelable +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.constraintlayout.compose.ConstraintLayout +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.core.screen.Screen +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.core.LocalCodeNavigator +import xyz.flipchat.app.R +import com.getcode.theme.CodeTheme +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import kotlinx.parcelize.Parcelize + +@Parcelize +data class NotificationPermissionScreen(val fromOnboarding: Boolean = false): Screen, Parcelable { + @Composable + override fun Content() { + NotificationPermission(fromOnboarding) + } + +} +@Composable +fun NotificationPermission(fromOnboarding: Boolean = false) { + val navigator = LocalCodeNavigator.current +// val analytics = LocalAnalytics.current + val onNotificationResult: (Boolean) -> Unit = { isGranted -> + if (isGranted) { + if (fromOnboarding) { +// analytics.action(Action.CompletedOnboarding) + } + if (navigator.lastModalItem is NotificationPermissionScreen) { + navigator.hide() + } else { + navigator.replaceAll(ScreenRegistry.get(NavScreenProvider.AppHomeScreen())) + } + } + } + val notificationPermissionCheck = + com.getcode.util.permissions.notificationPermissionCheck(onResult = { + onNotificationResult(it) + }) + + SideEffect { + notificationPermissionCheck(false) + } + + ConstraintLayout( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.navigationBars), + ) { + val (image, caption, button, buttonSkip) = createRefs() + + Image( + painter = painterResource(id = R.drawable.ic_notification_request), + contentDescription = "", + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.grid.x8) + .padding(top = CodeTheme.dimens.grid.x10) + .fillMaxHeight(0.6f) + .fillMaxWidth() + .constrainAs(image) { + top.linkTo(parent.top) + bottom.linkTo(caption.top) + start.linkTo(parent.start) + end.linkTo(parent.end) + } + ) + + Text( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .constrainAs(caption) { + top.linkTo(image.bottom) + bottom.linkTo(button.top) + start.linkTo(parent.start) + end.linkTo(parent.end) + }, + text = stringResource(R.string.permissions_description_push_messages), + style = CodeTheme.typography.textMedium + .copy(textAlign = TextAlign.Center), + ) + + CodeButton( + onClick = { notificationPermissionCheck(true) }, + text = stringResource(R.string.action_allowPushNotifications), + buttonState = ButtonState.Filled, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .constrainAs(button) { + start.linkTo(parent.start) + end.linkTo(parent.end) + linkTo(button.bottom, buttonSkip.top, bias = 1.0F) + }, + ) + + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = CodeTheme.dimens.grid.x2) + .padding(horizontal = CodeTheme.dimens.inset) + .constrainAs(buttonSkip) { + linkTo(buttonSkip.bottom, parent.bottom, bias = 1.0F) + start.linkTo(parent.start) + end.linkTo(parent.end) + }, + onClick = { + onNotificationResult(true) + }, + text = stringResource(R.string.action_notNow), + buttonState = ButtonState.Subtle, + ) + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/register/AccessKeyModalScreen.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/register/AccessKeyModalScreen.kt new file mode 100644 index 000000000..49e90794b --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/register/AccessKeyModalScreen.kt @@ -0,0 +1,48 @@ +package xyz.flipchat.app.features.login.register + +import android.os.Parcelable +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.hilt.getViewModel +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.modal.FullScreenModalScreen +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.utils.DisableSheetGestures +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import xyz.flipchat.app.features.login.accesskey.AccessKeyScreenContent +import xyz.flipchat.app.features.login.accesskey.LoginAccessKeyViewModel + +@Parcelize +class AccessKeyModalScreen : FullScreenModalScreen, Parcelable { + + @IgnoredOnParcel + override val key: ScreenKey = uniqueScreenKey + + @Composable + override fun ModalContent() { + val navigator = LocalCodeNavigator.current + val viewModel = getViewModel() + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle(backButton = false) + AccessKeyScreenContent(viewModel) { + navigator.push(ScreenRegistry.get(NavScreenProvider.CreateAccount.Purchase)) + } + } + + BackHandler { /* intercept */ } + DisableSheetGestures() + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/register/PurchaseAccountScreen.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/register/PurchaseAccountScreen.kt new file mode 100644 index 000000000..a229287bd --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/register/PurchaseAccountScreen.kt @@ -0,0 +1,260 @@ +package xyz.flipchat.app.features.login.register + +import android.os.Parcelable +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircleOutline +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import cafe.adriel.voyager.hilt.getViewModel +import com.getcode.manager.TopBarManager +import com.getcode.model.ID +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.modal.FullScreenModalScreen +import com.getcode.services.utils.onSuccessWithDelay +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.chat.UserAvatar +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.utils.DisableSheetGestures +import com.getcode.util.getActivity +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel2 +import com.getcode.view.LoadingSuccessState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import xyz.flipchat.app.R +import xyz.flipchat.app.auth.AuthManager +import xyz.flipchat.app.ui.LocalUserManager +import xyz.flipchat.controllers.PurchaseController +import xyz.flipchat.services.billing.BillingClient +import xyz.flipchat.services.billing.IapPaymentEvent +import xyz.flipchat.services.billing.IapProduct +import xyz.flipchat.services.billing.LocalBillingClient +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +@Parcelize +class PurchaseAccountScreen : FullScreenModalScreen, Parcelable { + + @Composable + override fun ModalContent() { + Column { + AppBarWithTitle( + backButton = false, + isInModal = true + ) + PurchaseAccountScreenContent(getViewModel()) + } + BackHandler { /** swallow **/ } + DisableSheetGestures() + } +} + +@Composable +private fun PurchaseAccountScreenContent(viewModel: PurchaseAccountViewModel) { + val navigator = LocalCodeNavigator.current + val context = LocalContext.current + val billingController = LocalBillingClient.current + val userManager = LocalUserManager.current + val composeScope = rememberCoroutineScope() + + val state by viewModel.stateFlow.collectAsState() + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.hideWithResult(userManager?.userFlags?.isRegistered == true) + }.launchIn(this) + } + + CodeScaffold( + bottomBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x2) + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1) + ) { + CodeButton( + modifier = Modifier + .fillMaxWidth(), + buttonState = ButtonState.Filled, + isLoading = state.creatingAccount.loading, + isSuccess = state.creatingAccount.success, + text = stringResource(R.string.action_purchaseAccount), + ) { + composeScope.launch { + context.getActivity()?.let { + billingController.purchase(it, IapProduct.CreateAccount) + } + } + } + } + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .align(Alignment.Center), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + horizontalAlignment = Alignment.CenterHorizontally + ) { + UserAvatar( + modifier = Modifier + .size(120.dp) + .clip(CircleShape), + data = state.userId, + overlay = { + Image( + modifier = Modifier.size(60.dp), + imageVector = Icons.Default.CheckCircleOutline, + colorFilter = ColorFilter.tint(Color.White), + contentDescription = null, + ) + } + ) + + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x10), + text = stringResource(R.string.title_finalizeAccountCreation), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain + ) + + Text( + text = stringResource( + R.string.subtitle_finalizeAccountCreation, + state.costOfAccount + ), + style = CodeTheme.typography.textMedium, + textAlign = TextAlign.Center, + color = CodeTheme.colors.textSecondary + ) + } + } + } +} + +@HiltViewModel +private class PurchaseAccountViewModel @Inject constructor( + private val userManager: UserManager, + private val authManager: AuthManager, + iapController: PurchaseController, + billingClient: BillingClient, + resources: ResourceHelper, +) : BaseViewModel2( + initialState = State(), + updateStateForEvent = updateStateForEvent +) { + data class State( + val userId: ID? = null, + val costOfAccount: String = "", + val creatingAccount: LoadingSuccessState = LoadingSuccessState(), + ) + + sealed interface Event { + data class OnUserIdChanged(val id: ID): Event + data class OnCostChanged(val cost: String): Event + data class OnCreatingChanged(val creating: Boolean, val created: Boolean = false) : Event + data object OnAccountCreated: Event + } + + init { + userManager.state + .mapNotNull { it.userId } + .onEach { dispatchEvent(Event.OnUserIdChanged(it)) } + .launchIn(viewModelScope) + + viewModelScope.launch { + dispatchEvent(Event.OnCostChanged(billingClient.costOf(IapProduct.CreateAccount))) + } + + billingClient.eventFlow + .mapNotNull { event -> + when (event) { + IapPaymentEvent.OnCancelled -> null + is IapPaymentEvent.OnError -> { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToPurchaseItem), + resources.getString(R.string.error_description_failedToPurchaseItem,) + ) + ) + null + } + is IapPaymentEvent.OnSuccess -> event + } + }.filterIsInstance() + .onEach { + dispatchEvent(Event.OnCreatingChanged(true)) + authManager.register(userManager.displayName!!) + .onSuccessWithDelay(2.seconds) { + dispatchEvent(Event.OnCreatingChanged(creating = false, created = true)) + delay(2.seconds) + dispatchEvent(Event.OnAccountCreated) + }.onFailure { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + resources.getString(R.string.error_title_failedToCreateAccount), + resources.getString(R.string.error_description_failedToCreateAccount) + ) + ) + } + } + .launchIn(viewModelScope) + } + + companion object { + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + Event.OnAccountCreated -> { state -> state } + is Event.OnCostChanged -> { state -> state.copy(costOfAccount = event.cost) } + is Event.OnCreatingChanged -> { state -> state.copy(creatingAccount = LoadingSuccessState(loading = event.creating, success = event.created)) } + is Event.OnUserIdChanged -> { state -> state.copy(userId = event.id) } + } + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/register/RegisterDisplayNameViewModel.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/register/RegisterDisplayNameViewModel.kt new file mode 100644 index 000000000..7601d02be --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/register/RegisterDisplayNameViewModel.kt @@ -0,0 +1,121 @@ +package xyz.flipchat.app.features.login.register + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.lifecycle.viewModelScope +import com.getcode.manager.TopBarManager +import com.getcode.view.BaseViewModel2 +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import xyz.flipchat.app.auth.AuthManager +import xyz.flipchat.controllers.CodeController +import xyz.flipchat.services.user.AuthState +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject + +@HiltViewModel +class RegisterDisplayNameViewModel @Inject constructor( + authManager: AuthManager, + codeController: CodeController, + userManager: UserManager, +): BaseViewModel2( + initialState = State(), + updateStateForEvent = updateStateForEvent +) { + data class State( + val checkingDisplayName: Boolean = false, + val isValidDisplayName: Boolean = false, + val textFieldState: TextFieldState = TextFieldState(), + ) { + val canAdvance: Boolean + get() = textFieldState.text.isNotEmpty() + } + + sealed interface Event { + data object RegisterDisplayName : Event + data object OnSuccess : Event + data class OnError(val reason: String) : Event + } + + init { + eventFlow + .filterIsInstance() + .map { stateFlow.value } + .map { + val textFieldState = it.textFieldState + val text = textFieldState.text.toString().trim() + + if (userManager.authState == AuthState.Unregistered) { + userManager.set(displayName = text) + Result.success(userManager.userId!!) + } else { + authManager.register(text) + } + } + .onResult( + onError = { dispatchEvent(Event.OnError(it.message ?: "Something went wrong")) }, + onSuccess = { dispatchEvent(Event.OnSuccess) } + ) + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + codeController.fetchBalance() + .onFailure { it.printStackTrace() } + .onSuccess { codeController.requestAirdrop() } + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + title = "Create Account Failed", + message = it.reason + ) + ) + }.launchIn(viewModelScope) + } + + internal companion object { + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + Event.RegisterDisplayName -> { state -> + state.copy(checkingDisplayName = true) + } + is Event.OnError -> { state -> + state.copy(checkingDisplayName = false) + } + Event.OnSuccess -> { state -> + state.copy(isValidDisplayName = true) + } + } + } + } +} + +fun Flow>.onResult(onError: (Throwable) -> Unit = { }, onSuccess: (T) -> Unit = { }): Flow> { + return this.map { + it.onSuccess(onSuccess).onFailure(onError) + } +} + +fun Flow>.mapResult(block: (T) -> R): Flow> { + return this.map { + if (it.isSuccess) { + Result.success(block(it.getOrNull()!!)) + } else { + Result.failure(it.exceptionOrNull() ?: Throwable("mapResult failed")) + } + } +} + +fun Flow>.onError(block: (Throwable) -> Unit): Flow> { + return this.map { + it.onFailure(block) + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/register/RegisterInfoScreen.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/register/RegisterInfoScreen.kt new file mode 100644 index 000000000..909206efe --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/register/RegisterInfoScreen.kt @@ -0,0 +1,173 @@ +package xyz.flipchat.app.features.login.register + +import android.os.Parcelable +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.hilt.getViewModel +import com.getcode.model.ID +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.modal.FullScreenModalScreen +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.chat.UserAvatar +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.parcelize.Parcelize +import xyz.flipchat.app.R +import xyz.flipchat.services.billing.BillingClient +import xyz.flipchat.services.billing.IapProduct +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject + +@Parcelize +class RegisterInfoScreen : FullScreenModalScreen, Parcelable { + + @Composable + override fun ModalContent() { + val navigator = LocalCodeNavigator.current + + Column { + AppBarWithTitle( + backButton = false, + endContent = { + AppBarDefaults.Close { navigator.hide() } + } + ) + RegisterInfoScreenContent( + viewModel = getViewModel(), + onGetStarted = { + navigator.push( + ScreenRegistry.get( + NavScreenProvider.CreateAccount.NameEntry( + showInModal = true + ) + ) + ) + }, + onNotNow = { + navigator.hide() + } + ) + } + } +} + +@Composable +private fun RegisterInfoScreenContent( + viewModel: RegisterInfoViewModel, + onGetStarted: () -> Unit, + onNotNow: () -> Unit +) { + CodeScaffold( + bottomBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x2) + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1) + ) { + CodeButton( + modifier = Modifier + .fillMaxWidth(), + buttonState = ButtonState.Filled, + text = stringResource(R.string.action_getStarted) + ) { + onGetStarted() + } + + CodeButton( + modifier = Modifier.fillMaxWidth(), + buttonState = ButtonState.Subtle, + text = stringResource(R.string.action_notNow), + ) { + onNotNow() + } + } + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .align(Alignment.Center), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + horizontalAlignment = Alignment.CenterHorizontally + ) { + UserAvatar( + modifier = Modifier + .size(120.dp) + .clip(CircleShape), + data = viewModel.userId, + overlay = { + Image( + modifier = Modifier.size(60.dp), + imageVector = Icons.Default.Person, + colorFilter = ColorFilter.tint(Color.White), + contentDescription = null, + ) + } + ) + + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x10), + text = stringResource(R.string.title_createAccountToJoinRooms), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain + ) + + Text( + text = stringResource(R.string.title_newAccountsCost, viewModel.costOfAccount), + textAlign = TextAlign.Center, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary + ) + } + } + } +} + +@HiltViewModel +private class RegisterInfoViewModel @Inject constructor( + private val userManager: UserManager, + private val iapController: BillingClient +) : ViewModel() { + + val userId: ID? + get() = userManager.userId + + val costOfAccount: String + get() = iapController.costOf(IapProduct.CreateAccount) +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/register/RegisterModalScreen.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/register/RegisterModalScreen.kt new file mode 100644 index 000000000..d491d81be --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/register/RegisterModalScreen.kt @@ -0,0 +1,51 @@ +package xyz.flipchat.app.features.login.register + +import android.os.Parcelable +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.hilt.getViewModel +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.modal.FullScreenModalScreen +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.keyboardAsState +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize + +@Parcelize +class RegisterModalScreen : FullScreenModalScreen, Parcelable { + + @Composable + override fun ModalContent() { + val navigator = LocalCodeNavigator.current + val keyboardIsVisible by keyboardAsState() + val keyboard = LocalSoftwareKeyboardController.current + val composeScope = rememberCoroutineScope() + + Column { + AppBarWithTitle( + backButton = false, + endContent = { + AppBarDefaults.Close { + composeScope.launch { + if (keyboardIsVisible) { + keyboard?.hide() + delay(500) + } + navigator.hide() + } + } + } + ) + RegisterDisplayNameScreenContent(getViewModel()) { + navigator.push(ScreenRegistry.get(NavScreenProvider.CreateAccount.AccessKey(true))) + } + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/register/RegisterScreen.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/register/RegisterScreen.kt new file mode 100644 index 000000000..e6e343830 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/login/register/RegisterScreen.kt @@ -0,0 +1,172 @@ +package xyz.flipchat.app.features.login.register + +import android.os.Parcelable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.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.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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.hilt.getViewModel +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.theme.CodeTheme +import com.getcode.theme.inputColors +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.TextInput +import com.getcode.ui.components.keyboardAsState +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.utils.ConstraintMode +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import xyz.flipchat.app.R +import xyz.flipchat.app.features.login.register.RegisterDisplayNameViewModel.Event + +@Parcelize +class RegisterScreen : Screen, Parcelable { + @Composable + override fun Content() { + val navigator = LocalCodeNavigator.current + + Column { + AppBarWithTitle( + backButton = true, + onBackIconClicked = navigator::pop + ) + RegisterDisplayNameScreenContent(getViewModel()) { + navigator.push(ScreenRegistry.get(NavScreenProvider.CreateAccount.AccessKey(false))) + } + } + } +} + +@Composable +internal fun RegisterDisplayNameScreenContent( + viewModel: RegisterDisplayNameViewModel, + onShowAccessKey: () -> Unit, +) { + val state by viewModel.stateFlow.collectAsState() + + val keyboardVisible by keyboardAsState() + val keyboardController = LocalSoftwareKeyboardController.current + val composeScope = rememberCoroutineScope() + var isChecking by remember(state.checkingDisplayName) { mutableStateOf(state.checkingDisplayName) } + + val register = { + composeScope.launch { + isChecking = true + if (keyboardVisible) { + keyboardController?.hide() + delay(500) + } + viewModel.dispatchEvent(Event.RegisterDisplayName) + } + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { delay(400) } + .onEach { onShowAccessKey() } + .launchIn(this) + } + + CodeScaffold( + modifier = Modifier + .fillMaxSize() + .imePadding(), + bottomBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(bottom = CodeTheme.dimens.grid.x3), + ) { + CodeButton( + enabled = state.canAdvance, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset), + buttonState = ButtonState.Filled, + text = stringResource(R.string.action_next), + isLoading = isChecking, + isSuccess = state.isValidDisplayName + ) { + register() + } + } + } + ) { padding -> + val focusRequester = remember { FocusRequester() } + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterStart) + .padding(horizontal = CodeTheme.dimens.inset), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset) + ) { + TextInput( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + state = state.textFieldState, + colors = inputColors( + backgroundColor = Color.Transparent, + borderColor = Color.Transparent + ), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences + ), + maxLines = 1, + constraintMode = ConstraintMode.AutoSize(minimum = CodeTheme.typography.displaySmall), + style = CodeTheme.typography.displayMedium, + placeholderStyle = CodeTheme.typography.displayMedium, + placeholder = stringResource(R.string.subtitle_yourName), + ) + + Text( + text = stringResource(R.string.subtitle_displayNameHint), + style = CodeTheme.typography.textMedium, + color = Color.White.copy(0.4f) + ) + } + } + + LaunchedEffect(focusRequester) { + focusRequester.requestFocus() + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/payments/MessageTipPaymentConfirmation.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/payments/MessageTipPaymentConfirmation.kt new file mode 100644 index 000000000..78251e7bb --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/payments/MessageTipPaymentConfirmation.kt @@ -0,0 +1,115 @@ +package xyz.flipchat.app.features.payments + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.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.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.stringResource +import com.getcode.model.Currency +import com.getcode.model.KinAmount +import com.getcode.model.Rate +import com.getcode.model.kin +import com.getcode.models.ConfirmationState +import com.getcode.models.MessageTipPaymentConfirmation +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.Modal +import com.getcode.ui.components.SlideToConfirm +import com.getcode.ui.components.picker.Picker +import com.getcode.ui.components.picker.PickerState +import com.getcode.ui.components.picker.rememberPickerState +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.util.resources.LocalResources +import com.getcode.utils.Kin +import com.getcode.utils.formatAmountString +import xyz.flipchat.app.R +import com.getcode.ui.components.R as uiR + +private val tipOptions = (1 until 100).map { KinAmount.newInstance(it.kin, Rate.oneToOne) } + +@Composable +internal fun MessageTipPaymentConfirmation( + modifier: Modifier = Modifier, + confirmation: MessageTipPaymentConfirmation?, + onSend: (KinAmount) -> Unit, + onCancel: () -> Unit, +) { + val state by remember(confirmation?.state) { + derivedStateOf { confirmation?.state } + } + + val isSending by remember(state) { + derivedStateOf { state is ConfirmationState.Sending } + } + + val resources = LocalResources.current!! + + val pickerState = rememberPickerState(items = tipOptions, prefix = "⬢") { item -> + formatAmountString( + resources = resources, + currency = Currency.Kin, + amount = item.kin.toKinValueDouble(), + suffix = resources.getKinSuffix() + ) + } + + Modal(modifier) { + if (state != null) { + MessageTipConfirmationContent( + pickerState = pickerState, + balance = confirmation?.balance, + isSending = isSending, + state = state, + onApproved = { + pickerState.selectedItem?.let { + onSend(it) + } + } + ) + val enabled = state !is ConfirmationState.Sending && state !is ConfirmationState.Sent + val alpha by animateFloatAsState(targetValue = if (enabled) 1f else 0f, label = "alpha") + CodeButton( + modifier = Modifier + .fillMaxWidth() + .alpha(alpha), + enabled = enabled, + buttonState = ButtonState.Subtle, + onClick = onCancel, + text = stringResource(id = android.R.string.cancel), + ) + } + } +} + +@Composable +private fun MessageTipConfirmationContent( + pickerState: PickerState, + balance: String?, + isSending: Boolean, + state: ConfirmationState?, + onApproved: () -> Unit +) { + Picker( + modifier = Modifier.fillMaxWidth(), + state = pickerState, + textStyle = CodeTheme.typography.displayMedium + ) + Text( + text = stringResource( + R.string.subtitle_balance, + balance.orEmpty(), + ), + style = CodeTheme.typography.textSmall.copy(color = CodeTheme.colors.tertiary), + ) + SlideToConfirm( + isLoading = isSending, + isSuccess = state is ConfirmationState.Sent, + onConfirm = onApproved, + label = stringResource(uiR.string.action_swipeToTip) + ) +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/payments/PaymentScaffold.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/payments/PaymentScaffold.kt new file mode 100644 index 000000000..f8017f9f2 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/payments/PaymentScaffold.kt @@ -0,0 +1,112 @@ +package xyz.flipchat.app.features.payments + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment.Companion.BottomCenter +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import com.getcode.models.BillState +import com.getcode.theme.Black40 +import com.getcode.ui.modals.TipConfirmation +import com.getcode.ui.utils.AnimationUtils +import com.getcode.ui.utils.ModalAnimationSpeed +import com.getcode.ui.utils.rememberedClickable +import xyz.flipchat.services.LocalPaymentController + +@Composable +fun PaymentScaffold(content: @Composable () -> Unit) { + val payments = LocalPaymentController.current ?: error("CompositionLocal is null") + + val state by payments.state.collectAsState() + Box(modifier = Modifier.fillMaxSize()) { + content() + val scrimDetails by rememberConfirmationDetails(state.billState) + + val scrimAlpha by animateFloatAsState(if (scrimDetails.show) 1f else 0f, label = "scrim visibility") + + if (scrimDetails.show) { + Box( + modifier = Modifier + .fillMaxSize() + .alpha(scrimAlpha) + .background(Black40) + .rememberedClickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + if (scrimDetails.cancellable) { + payments.cancelPayment() + } + } + ) + } + + // public payments + AnimatedContent( + modifier = Modifier.align(BottomCenter), + targetState = state.billState.publicPaymentConfirmation?.amount, // amount is constant across state changes + transitionSpec = AnimationUtils.modalAnimationSpec(speed = ModalAnimationSpeed.Fast), + label = "public payments", + ) { + if (it != null) { + Box( + contentAlignment = BottomCenter + ) { + PublicPaymentConfirmation( + confirmation = state.billState.publicPaymentConfirmation, + onSend = { payments.completePublicPayment() }, + onCancel = { payments.cancelPayment() } + ) + } + } + } + + AnimatedContent( + modifier = Modifier.align(BottomCenter), + targetState = state.billState.messageTipPaymentConfirmation?.balance, + transitionSpec = AnimationUtils.modalAnimationSpec(speed = ModalAnimationSpeed.Fast), + label = "message tip payments", + ) { + if (it != null) { + Box( + contentAlignment = BottomCenter + ) { + MessageTipPaymentConfirmation( + confirmation = state.billState.messageTipPaymentConfirmation, + onSend = { amount -> payments.completeMessageTip(amount) }, + onCancel = { payments.cancelPayment() } + ) + } + } + } + } +} + +data class ScrimDetails(val show: Boolean, val cancellable: Boolean) + +@Composable +private fun rememberConfirmationDetails(billState: BillState): State { + return remember(billState) { + derivedStateOf { + val publicPaymentConfirmation = billState.publicPaymentConfirmation + val messageTipPaymentConfirmation = billState.messageTipPaymentConfirmation + + listOf( + publicPaymentConfirmation, + messageTipPaymentConfirmation + ).firstNotNullOfOrNull { it }?.let { conf -> + ScrimDetails(conf.showScrim, conf.cancellable) + } ?: ScrimDetails(show = false, cancellable = false) + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/payments/PublicPaymentConfirmation.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/payments/PublicPaymentConfirmation.kt new file mode 100644 index 000000000..0e80e9335 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/payments/PublicPaymentConfirmation.kt @@ -0,0 +1,90 @@ +package xyz.flipchat.app.features.payments + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.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.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.getcode.model.KinAmount +import com.getcode.models.ConfirmationState +import com.getcode.models.PublicPaymentConfirmation +import com.getcode.theme.CodeTheme +import com.getcode.theme.bolded +import com.getcode.ui.components.Modal +import com.getcode.ui.components.PriceWithFlag +import com.getcode.ui.components.SlideToConfirm +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton + +@Composable +internal fun PublicPaymentConfirmation( + modifier: Modifier = Modifier, + confirmation: PublicPaymentConfirmation?, + onSend: () -> Unit, + onCancel: () -> Unit, +) { + val state by remember(confirmation?.state) { + derivedStateOf { confirmation?.state } + } + + val isSending by remember(state) { + derivedStateOf { state is ConfirmationState.Sending } + } + + val requestedAmount by remember(confirmation?.amount?.kin?.quarks) { + derivedStateOf { confirmation?.amount } + } + + Modal(modifier) { + val amount = requestedAmount + if (state != null && amount != null) { + PaymentConfirmationContent( + amount = amount, + isSending = isSending, + state = state, + onApproved = onSend + ) + val enabled = state !is ConfirmationState.Sending && state !is ConfirmationState.Sent + val alpha by animateFloatAsState(targetValue = if (enabled) 1f else 0f, label = "alpha") + CodeButton( + modifier = Modifier.fillMaxWidth().alpha(alpha), + enabled = enabled, + buttonState = ButtonState.Subtle, + onClick = onCancel, + text = stringResource(id = android.R.string.cancel), + ) + } + } +} + +@Composable +private fun PaymentConfirmationContent( + amount: KinAmount, + isSending: Boolean, + state: ConfirmationState?, + onApproved: () -> Unit +) { + PriceWithFlag( + currencyCode = amount.rate.currency, + amount = amount, + iconSize = 24.dp + ) { + Text( + text = it, + color = Color.White, + style = CodeTheme.typography.displayMedium.bolded() + ) + } + SlideToConfirm( + isLoading = isSending, + isSuccess = state is ConfirmationState.Sent, + onConfirm = { onApproved() }, + ) +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/restricted/AppRestrictedScreen.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/restricted/AppRestrictedScreen.kt new file mode 100644 index 000000000..60555326e --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/restricted/AppRestrictedScreen.kt @@ -0,0 +1,27 @@ +package xyz.flipchat.app.features.restricted + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.core.screen.Screen +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.extensions.getActivityScopedViewModel +import com.getcode.ui.components.restrictions.ContentRestrictedView +import com.getcode.ui.components.restrictions.RestrictionType +import kotlinx.parcelize.Parcelize +import xyz.flipchat.app.features.home.HomeViewModel + +@Parcelize +class AppRestrictedScreen(private val restrictionType: RestrictionType): Screen, Parcelable { + @Composable + override fun Content() { + val homeViewModel = getActivityScopedViewModel() + val navigator = LocalCodeNavigator.current + ContentRestrictedView(restrictionType) { + homeViewModel.logout(it) { + navigator.replaceAll(ScreenRegistry.get(NavScreenProvider.Login.Home())) + } + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/settings/SettingsScreen.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/settings/SettingsScreen.kt new file mode 100644 index 000000000..752f8d9b7 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/settings/SettingsScreen.kt @@ -0,0 +1,133 @@ +package xyz.flipchat.app.features.settings + +import android.os.Parcelable +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Science +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.hilt.getViewModel +import com.getcode.manager.BottomBarManager +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.SettingsRow +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.utils.getActivity +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import xyz.flipchat.app.R + +@Parcelize +class SettingsScreen : Screen, Parcelable { + @IgnoredOnParcel + override val key: ScreenKey = uniqueScreenKey + + @Composable + override fun Content() { + val viewModel = getViewModel() + val navigator = LocalCodeNavigator.current + val context = LocalContext.current + val composeScope = rememberCoroutineScope() + + val state by viewModel.stateFlow.collectAsState() + + CodeScaffold( + modifier = Modifier + .fillMaxSize() + .padding( + vertical = CodeTheme.dimens.grid.x2, + ), + bottomBar = { + LogoutButton { + composeScope.launch { + delay(150) // wait for dismiss + context.getActivity()?.let { + viewModel.logout(it) { + navigator.replaceAll(ScreenRegistry.get(NavScreenProvider.Login.Home())) + } + } + } + } + } + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + item { + Box(modifier = Modifier.fillParentMaxWidth()) { + Image( + painter = painterResource(R.drawable.flipchat_logo), + contentDescription = "", + modifier = Modifier + .padding(vertical = CodeTheme.dimens.inset) + .align(Alignment.Center) + .size(CodeTheme.dimens.staticGrid.x12), + + ) + } + } + if (state.isStaff) { + item { + SettingsRow( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + title = stringResource(R.string.title_betaFlags), + icon = rememberVectorPainter(Icons.Outlined.Science) + ) { + navigator.push(ScreenRegistry.get(NavScreenProvider.BetaFlags)) + } + } + } + } + } + } +} + +@Composable +private fun LogoutButton( + onConfirmed: () -> Unit +) { + val context = LocalContext.current + CodeButton( + modifier = Modifier.fillMaxWidth().padding(horizontal = CodeTheme.dimens.inset), + buttonState = ButtonState.Filled, + text = stringResource(R.string.action_logout) + ) { + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = context.getString(R.string.prompt_title_logout), + subtitle = context + .getString(R.string.prompt_description_logout), + positiveText = context.getString(R.string.action_logout), + tertiaryText = context.getString(R.string.action_cancel), + onPositive = { + onConfirmed() + } + ) + ) + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/settings/SettingsViewModel.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/settings/SettingsViewModel.kt new file mode 100644 index 000000000..3bc776a2a --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/features/settings/SettingsViewModel.kt @@ -0,0 +1,65 @@ +package xyz.flipchat.app.features.settings + +import android.app.Activity +import androidx.lifecycle.viewModelScope +import com.getcode.view.BaseViewModel2 +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import xyz.flipchat.app.auth.AuthManager +import xyz.flipchat.controllers.ChatsController +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val authManager: AuthManager, + private val chatsController: ChatsController, + userManager: UserManager, +) : BaseViewModel2( + initialState = State(), + updateStateForEvent = updateStateForEvent +) { + data class State( + val isStaff: Boolean = false, + ) + + sealed interface Event { + data class OnStaffEmployed(val enabled: Boolean) : Event + } + + init { + userManager.state + .mapNotNull { it.flags } + .map { it.isStaff } + .onEach { dispatchEvent(Event.OnStaffEmployed(it)) } + .launchIn(viewModelScope) + } + + fun logout(activity: Activity, onComplete: () -> Unit) = viewModelScope.launch { + authManager.logout(activity) + .onSuccess { + chatsController.closeEventStream() + onComplete() + } + } + + fun deleteAccount(activity: Activity, onComplete: () -> Unit) = viewModelScope.launch { + authManager.deleteAndLogout(activity) + .onSuccess { + chatsController.closeEventStream() + onComplete() + } + } + + internal companion object { + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + is Event.OnStaffEmployed -> { state -> state.copy(isStaff = event.enabled) } + } + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/inject/ApiModule.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/inject/ApiModule.kt new file mode 100644 index 000000000..87cc8306a --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/inject/ApiModule.kt @@ -0,0 +1,74 @@ +package xyz.flipchat.app.inject + +import android.content.Context +import com.getcode.model.Currency +import xyz.flipchat.app.util.AccountAuthenticator +import com.getcode.services.db.CurrencyProvider +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.Kin +import com.mixpanel.android.mpmetrics.MixpanelAPI +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import io.reactivex.rxjava3.core.Scheduler +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.schedulers.Schedulers +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import xyz.flipchat.app.BuildConfig +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ApiModule { + + @Singleton + @Provides + fun provideCompositeDisposable(): CompositeDisposable { + return CompositeDisposable() + } + + @Provides + fun provideScheduler(): Scheduler = Schedulers.io() + + @Singleton + @Provides + fun provideAccountAuthenticator( + @ApplicationContext context: Context, + ): AccountAuthenticator { + return AccountAuthenticator(context) + } + + @Singleton + @Provides + fun provideMixpanelApi(@ApplicationContext context: Context): MixpanelAPI { + return MixpanelAPI.getInstance(context, BuildConfig.MIXPANEL_API_KEY) + } + + @Singleton + @Provides + fun providesHttpLoggingInterceptor() = HttpLoggingInterceptor() + .apply { + level = HttpLoggingInterceptor.Level.BODY + } + + @Singleton + @Provides + fun providesOkHttpClient(httpLoggingInterceptor: HttpLoggingInterceptor): OkHttpClient = + OkHttpClient + .Builder() + .addInterceptor(httpLoggingInterceptor) + .build() + + @Singleton + @Provides + fun providesCurrencyProvider( + resources: ResourceHelper + ): CurrencyProvider = object : CurrencyProvider { + override suspend fun preferredCurrency(): Currency = Currency.Kin + override suspend fun defaultCurrency(): Currency = Currency.Kin + override fun suffix(currency: Currency?): String = resources.getKinSuffix() + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/inject/AppModule.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/inject/AppModule.kt new file mode 100644 index 000000000..243b2f8bf --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/inject/AppModule.kt @@ -0,0 +1,185 @@ +package xyz.flipchat.app.inject + +import android.annotation.SuppressLint +import android.content.ClipboardManager +import android.content.Context +import android.net.ConnectivityManager +import android.net.wifi.WifiManager +import android.os.Build +import android.os.VibratorManager +import android.telephony.TelephonyManager +import androidx.core.app.NotificationManagerCompat +import com.getcode.libs.opengraph.OpenGraphCacheProvider +import com.getcode.libs.opengraph.OpenGraphParser +import com.getcode.libs.opengraph.cache.CacheProvider +import com.getcode.services.analytics.AnalyticsService +import com.getcode.services.analytics.AnalyticsServiceNull +import com.getcode.util.locale.LocaleHelper +import com.getcode.util.resources.AndroidResources +import com.getcode.util.resources.ResourceHelper +import com.getcode.util.vibration.Api25Vibrator +import com.getcode.util.vibration.Api26Vibrator +import com.getcode.util.vibration.Api31Vibrator +import com.getcode.util.vibration.Vibrator +import com.getcode.utils.CurrencyUtils +import com.getcode.utils.network.Api24NetworkObserver +import com.getcode.utils.network.Api29NetworkObserver +import com.getcode.utils.network.NetworkConnectivityListener +import com.mixpanel.android.mpmetrics.MixpanelAPI +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import xyz.flipchat.app.BuildConfig +import xyz.flipchat.app.beta.LabsController +import xyz.flipchat.app.beta.Labs +import xyz.flipchat.app.util.AndroidLocale +import xyz.flipchat.app.util.FcTab +import xyz.flipchat.app.util.Router +import xyz.flipchat.app.util.RouterImpl +import xyz.flipchat.controllers.ChatsController +import xyz.flipchat.services.billing.BillingClient +import xyz.flipchat.services.billing.GooglePlayBillingClient +import xyz.flipchat.services.billing.StubBillingClient +import xyz.flipchat.services.internal.network.repository.iap.InAppPurchaseRepository +import xyz.flipchat.services.user.UserManager +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + @Provides + fun providesResourceHelper( + @ApplicationContext context: Context, + ): ResourceHelper = AndroidResources(context) + + @Provides + fun providesLocaleHelper( + @ApplicationContext context: Context, + currencyUtils: CurrencyUtils, + ): LocaleHelper = AndroidLocale(context, currencyUtils) + + @Provides + fun providesWifiManager( + @ApplicationContext context: Context, + ): WifiManager = + context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + + @Provides + fun providesConnectivityManager( + @ApplicationContext context: Context, + ): ConnectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + @Provides + fun providesTelephonyManager( + @ApplicationContext context: Context, + ): TelephonyManager = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager + + @Provides + fun providesClipboard( + @ApplicationContext context: Context + ): ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + + + @Provides + fun providesNotificationManager( + @ApplicationContext context: Context + ): NotificationManagerCompat = NotificationManagerCompat.from(context) + + @SuppressLint("NewApi") + @Provides + @Singleton + fun providesVibrator( + @ApplicationContext context: Context + ): Vibrator = when (val apiLevel = Build.VERSION.SDK_INT) { + in Build.VERSION_CODES.BASE..Build.VERSION_CODES.R -> { + val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as android.os.Vibrator + if (apiLevel >= Build.VERSION_CODES.O) { + Api26Vibrator(vibrator) + } else { + Api25Vibrator(vibrator) + } + } + + else -> Api31Vibrator(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager) + } + + @Provides + @SuppressLint("NewApi") + @Singleton + fun providesNetworkObserver( + connectivityManager: ConnectivityManager, + telephonyManager: TelephonyManager, + wifiManager: WifiManager + ): NetworkConnectivityListener = when (Build.VERSION.SDK_INT) { + in Build.VERSION_CODES.N..Build.VERSION_CODES.P -> { + Api24NetworkObserver( + wifiManager, + connectivityManager, + telephonyManager + ) + } + + else -> Api29NetworkObserver( + connectivityManager, + telephonyManager + ) + } + + // TODO: + @Provides + fun providesAnalyticsService( + mixpanelAPI: MixpanelAPI + ): AnalyticsService = AnalyticsServiceNull() + + @Provides + fun providesDeeplinkRouter( + userManager: UserManager, + chatsController: ChatsController, + resources: ResourceHelper, + ): Router = RouterImpl( + userManager = userManager, + chatsController = chatsController, + resources = resources, + tabIndexResolver = { resolved -> + when (resolved) { + FcTab.Chat -> FcTab.Chat.ordinal + FcTab.Cash -> FcTab.Cash.ordinal + FcTab.Settings -> FcTab.Settings.ordinal + } + }, + indexTabResolver = { index -> FcTab.entries[index] } + ) + + @Singleton + @Provides + fun providesBetaController( + @ApplicationContext context: Context + ): Labs = LabsController(context) + + @Singleton + @Provides + fun providesBillingController( + @ApplicationContext context: Context, + userManager: UserManager, + purchaseRepository: InAppPurchaseRepository + ): BillingClient = if (BuildConfig.DEBUG) { + StubBillingClient + } else { + GooglePlayBillingClient(context, userManager, purchaseRepository) + } + + @Singleton + @Provides + fun providesOpenGraphCache( + @ApplicationContext context: Context, + ): CacheProvider = OpenGraphCacheProvider(context) + + @Singleton + @Provides + fun providesOpenGraphParser( + cache: CacheProvider + ): OpenGraphParser = OpenGraphParser(cacheProvider = cache) +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/inject/TipModule.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/inject/TipModule.kt new file mode 100644 index 000000000..20f6df8ff --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/inject/TipModule.kt @@ -0,0 +1,27 @@ +package xyz.flipchat.app.inject + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dev.bmcreations.tipkit.engines.EventEngine +import dev.bmcreations.tipkit.engines.TipsEngine +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object TipModule { + @Provides + @Singleton + fun providesTipEngine( + eventEngine: EventEngine + ) = TipsEngine(eventEngine) + + @Singleton + @Provides + fun providesEventEngine( + @ApplicationContext context: Context + ) = EventEngine(context) +} diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/notifications/FcNotificationReceiver.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/notifications/FcNotificationReceiver.kt new file mode 100644 index 000000000..d40809408 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/notifications/FcNotificationReceiver.kt @@ -0,0 +1,132 @@ +package xyz.flipchat.app.notifications + +import android.annotation.SuppressLint +import android.app.Notification +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person +import androidx.core.app.RemoteInput +import com.getcode.vendor.Base58 +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import xyz.flipchat.app.auth.AuthManager +import xyz.flipchat.chat.RoomController +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +@AndroidEntryPoint +class FcNotificationReceiver : BroadcastReceiver() { + + @Inject + lateinit var authManager: AuthManager + + @Inject + lateinit var userManager: UserManager + + @Inject + lateinit var roomController: RoomController + + @Inject + lateinit var notificationManager: NotificationManagerCompat + + override fun onReceive(context: Context, intent: Intent) { + val remoteInput = RemoteInput.getResultsFromIntent(intent) + if (remoteInput != null) { + val roomId = runCatching { + Base58.decode( + intent.getStringExtra(FcNotificationService.KEY_ROOM_ID).orEmpty() + ).toList() + }.getOrNull() + + val notificationId = intent.getIntExtra(FcNotificationService.KEY_NOTIFICATION_ID, -1).takeIf { it > 0 } + if (notificationId != null) { + val activeNotification = notificationManager.getActiveNotification(notificationId) + if (activeNotification != null) { + if (roomId != null) { + val message = + remoteInput.getCharSequence(FcNotificationService.KEY_TEXT_REPLY).toString() + authenticateIfNeeded { + goAsync { + roomController.sendMessage(roomId, message) + .onFailure { + it.printStackTrace() + }.onSuccess { + println("Message sent via notification!") + addReply(context, message, notificationId, activeNotification) + } + } + } + } + } + } + } + } + + @SuppressLint("MissingPermission") + private fun addReply( + context: Context, + text: String, + notificationId: Int, + activeNotification: Notification + ) { + val activeStyle = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(activeNotification) ?: return + + // Recover builder from the active notification. + val recoveredBuilder = NotificationCompat.Builder(context, activeNotification) + + val person = Person.Builder() + .setName("You") + .build() + + val message = NotificationCompat.MessagingStyle.Message( + text, + Clock.System.now().toEpochMilliseconds(), + person + ) + + val newStyle = NotificationCompat.MessagingStyle(person) + .setConversationTitle(activeStyle.conversationTitle) + + activeStyle.messages.onEach { newStyle.addMessage(it) } + + newStyle.addMessage(message) + + // Set the new style to the recovered builder. + recoveredBuilder.setStyle(newStyle) + + // Update the active notification. + NotificationManagerCompat.from(context).notify(notificationId, recoveredBuilder.build()) + } + + private fun authenticateIfNeeded(block: () -> Unit) { + if (userManager.userId == null) { + authManager.init { block() } + } else { + block() + } + } +} + +fun BroadcastReceiver.goAsync( + context: CoroutineContext = EmptyCoroutineContext, + block: suspend CoroutineScope.() -> Unit +) { + val pendingResult = goAsync() + @OptIn(DelicateCoroutinesApi::class) // Must run globally; there's no teardown callback. + GlobalScope.launch(context) { + try { + block() + } finally { + pendingResult.finish() + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/notifications/FcNotificationService.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/notifications/FcNotificationService.kt new file mode 100644 index 000000000..a08042fd1 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/notifications/FcNotificationService.kt @@ -0,0 +1,237 @@ +package xyz.flipchat.app.notifications + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.media.RingtoneManager +import android.net.Uri +import android.os.Build +import androidx.compose.ui.graphics.toArgb +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person +import androidx.core.app.RemoteInput +import com.getcode.ui.components.chat.utils.localizedText +import com.getcode.util.resources.ResourceHelper +import com.getcode.util.resources.ResourceType +import com.getcode.utils.CurrencyUtils +import com.getcode.utils.TraceType +import com.getcode.utils.base58 +import com.getcode.utils.trace +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import timber.log.Timber +import xyz.flipchat.app.MainActivity +import xyz.flipchat.app.R +import xyz.flipchat.app.auth.AuthManager +import xyz.flipchat.app.theme.FC_Primary +import xyz.flipchat.controllers.ChatsController +import xyz.flipchat.controllers.PushController +import xyz.flipchat.internal.db.FcAppDatabase +import xyz.flipchat.notifications.FcNotificationType +import xyz.flipchat.notifications.parse +import xyz.flipchat.services.user.AuthState +import xyz.flipchat.services.user.UserManager +import java.security.SecureRandom +import javax.inject.Inject + +@AndroidEntryPoint +class FcNotificationService : FirebaseMessagingService(), + CoroutineScope by CoroutineScope(Dispatchers.IO) { + + companion object { + const val KEY_NOTIFICATION_ID = "key_notification_id" + const val KEY_TEXT_REPLY = "key_text_reply" + const val KEY_ROOM_ID = "key_room_id" + } + + @Inject + lateinit var authManager: AuthManager + + @Inject + lateinit var userManager: UserManager + + @Inject + lateinit var pushController: PushController + + @Inject + lateinit var chatsController: ChatsController + + @Inject + lateinit var resources: ResourceHelper + + @Inject + lateinit var currencyUtils: CurrencyUtils + + @Inject + lateinit var notificationManager: NotificationManagerCompat + + private val db: FcAppDatabase + get() = FcAppDatabase.requireInstance() + + override fun onMessageReceived(message: RemoteMessage) { + super.onMessageReceived(message) + authenticateIfNeeded { handleMessage(message) } + } + + override fun onNewToken(token: String) { + super.onNewToken(token) + authenticateIfNeeded { + launch { + pushController.addToken(token) + .onSuccess { trace("push token updated", type = TraceType.Process) } + } + } + } + + private fun handleMessage(remoteMessage: RemoteMessage) { + launch { + trace("handling received message", type = TraceType.Silent) + if (remoteMessage.data.isNotEmpty()) { + Timber.d("Message data payload: ${remoteMessage.data}") + val notification = remoteMessage.parse() + + if (notification != null) { + val (type, titleKey, messageContent) = notification + if (type.isNotifiable()) { + val title = titleKey.localizedStringByKey(resources) ?: titleKey + val body = messageContent.localizedText( + resources = resources, + currencyUtils = currencyUtils + ) + + val result = buildNotification(type, title, body) + if (result != null) { + notify(result.first, result.second, type) + } + } + + when (type) { + is FcNotificationType.ChatMessage -> { + val roomId = type.id + if (roomId != null) { + launch { chatsController.updateRoom(roomId) } + } + } + + FcNotificationType.Unknown -> Unit + } + } else { + val result = buildNotification( + FcNotificationType.Unknown, + resources.getString(R.string.app_name), + "You have a new message." + ) + if (result != null) { + notify(result.first, result.second, FcNotificationType.Unknown) + } + } + } + } + } + + private fun authenticateIfNeeded(block: () -> Unit) { + if (userManager.userId == null) { + authManager.init { block() } + } else { + block() + } + } + + private suspend fun buildNotification( + type: FcNotificationType, + title: String, + content: String, + ): Pair? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + notificationManager.createNotificationChannel( + NotificationChannel( + type.name, + type.name, + NotificationManager.IMPORTANCE_DEFAULT + ) + ) + } + + if (type is FcNotificationType.ChatMessage && type.id == userManager.openRoom) { + return null + } + + + with(notificationManager) { + val (id, notification) = when (type) { + is FcNotificationType.ChatMessage -> { + val roomNumber = type.id?.let { db.conversationDao().findConversationRaw(it)?.roomNumber } + val isFullMember = db.conversationMembersDao() + .getMemberIn(memberId = userManager.userId.orEmpty(), type.id.orEmpty())?.isFullMember == true + + buildChatNotification( + applicationContext, + resources, + type, + roomNumber, + title, + content, + userManager.authState is AuthState.LoggedIn && isFullMember + ) + } + + FcNotificationType.Unknown -> buildMiscNotification(applicationContext, type, title, content) + } + + return id to notification.build() + } + } + + private fun notify( + id: Int, + notification: Notification, + type: FcNotificationType, + ) { + if (ActivityCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + ) { + notificationManager.notify(id, notification) + trace( + tag = "Push", + message = "Push notification shown", + metadata = { + "category" to type.name + }, + type = TraceType.Process + ) + } else { + trace( + tag = "Push", + message = "Push notification NOT shown - missing permission", + metadata = { + "category" to type.name + }, + type = TraceType.Process + ) + } + } +} + +private fun String.localizedStringByKey(resources: ResourceHelper): String? { + val name = this.replace(".", "_") + val resId = resources.getIdentifier( + name, + ResourceType.String, + ).let { if (it == 0) null else it } + + return resId?.let { resources.getString(it) } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/notifications/NotificationHelper.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/notifications/NotificationHelper.kt new file mode 100644 index 000000000..8d67e56e1 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/notifications/NotificationHelper.kt @@ -0,0 +1,157 @@ +package xyz.flipchat.app.notifications + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.media.RingtoneManager +import android.net.Uri +import androidx.compose.ui.graphics.toArgb +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person +import androidx.core.app.RemoteInput +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.base58 +import kotlinx.datetime.Clock +import xyz.flipchat.app.MainActivity +import xyz.flipchat.app.R +import xyz.flipchat.app.notifications.FcNotificationService.Companion.KEY_NOTIFICATION_ID +import xyz.flipchat.app.notifications.FcNotificationService.Companion.KEY_ROOM_ID +import xyz.flipchat.app.notifications.FcNotificationService.Companion.KEY_TEXT_REPLY +import xyz.flipchat.app.theme.FC_Primary +import xyz.flipchat.notifications.FcNotificationType +import java.security.SecureRandom + +internal fun NotificationManagerCompat.buildChatNotification( + context: Context, + resources: ResourceHelper, + type: FcNotificationType.ChatMessage, + roomNumber: Long?, + title: String, + content: String, + canReply: Boolean, +): Pair { + val sender = content.substringBefore(":").trim().ifEmpty { type.sender } ?: "Sender" + val messageBody = content.substringAfter(":").trim() + val person = Person.Builder() + .setName(sender) + .build() + + val message = NotificationCompat.MessagingStyle.Message( + messageBody, + Clock.System.now().toEpochMilliseconds(), + person + ) + + val notificationId = type.id?.base58.hashCode() + + val style = getActiveNotification(notificationId)?.let { + NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(it) + } ?: NotificationCompat.MessagingStyle(person) + .setConversationTitle(title) + .setGroupConversation(true) + + val updatedStyle = style.addMessage(message) + + val replyAction = if (type.id != null && canReply) { + // build direct reply action + val replyLabel: String = resources.getString(R.string.action_reply) + val remoteInput: RemoteInput = RemoteInput.Builder(KEY_TEXT_REPLY).run { + setLabel(replyLabel) + build() + } + + val resultIntent = Intent(context, FcNotificationReceiver::class.java).apply { + putExtra(KEY_ROOM_ID, type.id!!.base58) + putExtra(KEY_NOTIFICATION_ID, notificationId) + } + + val replyPendingIntent: PendingIntent = + PendingIntent.getBroadcast( + context, + type.id.hashCode(), + resultIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + ) + + NotificationCompat.Action.Builder( + R.drawable.ic_reply, + context.getString(R.string.action_reply), + replyPendingIntent + ).addRemoteInput(remoteInput).build() + } else { + null + } + + val notificationBuilder: NotificationCompat.Builder = + NotificationCompat.Builder(context, type.name) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setStyle(updatedStyle) + .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) + .setSmallIcon(R.drawable.ic_flipchat_notification) + .setColor(FC_Primary.toArgb()) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setContentIntent(context.buildContentIntent(type.copy(roomNumber = roomNumber))) + + if (replyAction != null) { + notificationBuilder.addAction(replyAction) + } + + return notificationId to notificationBuilder +} + +internal fun NotificationManagerCompat.buildMiscNotification( + context: Context, + type: FcNotificationType, + title: String, + content: String +): Pair { + val notificationBuilder: NotificationCompat.Builder = + NotificationCompat.Builder(context, type.name) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) + .setSmallIcon(R.drawable.ic_flipchat_notification) + .setColor(FC_Primary.toArgb()) + .setAutoCancel(true) + .setContentTitle(title) + .setContentText(content) + .setContentIntent(context.buildContentIntent(type)) + + val random = SecureRandom() + val notificationId = random.nextInt(256) + + return notificationId to notificationBuilder +} + +internal fun NotificationManagerCompat.getActiveNotification(notificationId: Int): Notification? { + val barNotifications = activeNotifications + for (notification in barNotifications) { + if (notification.id == notificationId) { + return notification.notification + } + } + return null +} + +internal fun Context.buildContentIntent( + type: FcNotificationType +): PendingIntent { + val launchIntent = when (type) { + is FcNotificationType.ChatMessage -> Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse("https://app.flipchat.xyz/room/${type.roomNumber}") + } + + FcNotificationType.Unknown -> Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + } + + return PendingIntent.getActivity( + this, + type.ordinal, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/theme/FlipchatTheme.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/theme/FlipchatTheme.kt new file mode 100644 index 000000000..55a3e7701 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/theme/FlipchatTheme.kt @@ -0,0 +1,63 @@ +package xyz.flipchat.app.theme + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import com.getcode.theme.Alert +import com.getcode.theme.BrandLight +import com.getcode.theme.BrandMuted +import com.getcode.theme.BrandOverlay +import com.getcode.theme.CodeTypography +import com.getcode.theme.ColorScheme +import com.getcode.theme.DesignSystem +import com.getcode.theme.Error +import com.getcode.theme.Gray50 +import com.getcode.theme.TextMain +import com.getcode.theme.White +import com.getcode.theme.codeTypography + +val FC_Primary = Color(0xFF362774) +private val FC_Secondary = Color(0xFF443091) +private val FC_Tertiary = Color(0xFF7D6CC3) +private val FC_TextWithPrimary = Color(0xFFD2C6FF) +private val FC_Accent = Color(0xFFC372FF) + +private val colors = ColorScheme( + brand = FC_Primary, + brandLight = BrandLight, + brandSubtle = FC_Secondary, + brandMuted = BrandMuted, + brandDark = Color(0xFF2C2158), + brandOverlay = BrandOverlay, + brandContainer = FC_Primary, + secondary = FC_Secondary, + tertiary = FC_Tertiary, + indicator = FC_Accent, + action = Gray50, + onAction = White, + background = FC_Primary, + onBackground = White, + surface = Color(0xFF28176E), + surfaceVariant = FC_Secondary, + onSurface = White, + error = Error, + errorText = Alert, + success = FC_Accent, + textMain = TextMain, + textSecondary = FC_TextWithPrimary, + divider = FC_Secondary, + dividerVariant = FC_Tertiary, + trackColor = Color(0xFF241A4B) +) + +@Composable +fun FlipchatTheme(content: @Composable () -> Unit) { + DesignSystem( + colorScheme = colors, + // override code type system to make screen title's slightly bigger + typography = codeTypography.copy( + screenTitle = codeTypography.displayExtraSmall.copy(fontWeight = FontWeight.W500) + ), + content = content + ) +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/ui/AmountWithKeypad.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/ui/AmountWithKeypad.kt new file mode 100644 index 000000000..faf7fd228 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/ui/AmountWithKeypad.kt @@ -0,0 +1,69 @@ +package xyz.flipchat.app.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.text.AmountAnimatedInputUiModel +import com.getcode.ui.components.text.AmountArea +import com.getcode.ui.theme.CodeKeyPad +import com.getcode.utils.network.LocalNetworkObserver +import xyz.flipchat.app.R + +@Composable +fun AmountWithKeypad( + modifier: Modifier = Modifier, + amountAnimatedModel: AmountAnimatedInputUiModel, + prefix: String = "", + placeholder: String = "", + isKin: Boolean = false, + hint: String = "", + onNumberPressed: (Int) -> Unit, + onBackspace: () -> Unit, +) { + val networkObserver = LocalNetworkObserver.current + val networkState by networkObserver.state.collectAsState() + + Column( + modifier = modifier, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(0.65f) + ) { + AmountArea( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center) + .padding(horizontal = CodeTheme.dimens.inset), + amountPrefix = prefix, + amountText = "0", + placeholder = placeholder, + captionText = hint, + currencyResId = if (isKin) R.drawable.ic_currency_kin else null, + isAltCaptionKinIcon = false, + uiModel = amountAnimatedModel, + isAnimated = true, + isClickable = false, + networkState = networkState, + textStyle = CodeTheme.typography.displayLarge, + ) + } + + CodeKeyPad( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = CodeTheme.dimens.inset) + .weight(1f), + onNumber = onNumberPressed, + onClear = onBackspace, + ) + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/ui/Locals.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/ui/Locals.kt new file mode 100644 index 000000000..798217323 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/ui/Locals.kt @@ -0,0 +1,9 @@ +package xyz.flipchat.app.ui + +import androidx.compose.runtime.staticCompositionLocalOf +import xyz.flipchat.app.beta.Labs +import xyz.flipchat.app.beta.NoOpLabs +import xyz.flipchat.services.user.UserManager + +val LocalUserManager = staticCompositionLocalOf { null } +val LocalLabs = staticCompositionLocalOf { NoOpLabs } \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/ui/navigation/AppScreenContent.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/ui/navigation/AppScreenContent.kt new file mode 100644 index 000000000..0c09bcc72 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/ui/navigation/AppScreenContent.kt @@ -0,0 +1,118 @@ +package xyz.flipchat.app.ui.navigation + +import androidx.compose.runtime.Composable +import cafe.adriel.voyager.core.registry.ScreenRegistry +import com.getcode.navigation.NavScreenProvider +import xyz.flipchat.app.features.balance.BalanceScreen +import xyz.flipchat.app.features.beta.BetaFlagsScreen +import xyz.flipchat.app.features.chat.conversation.ConversationScreen +import xyz.flipchat.app.features.chat.cover.CoverChargeScreen +import xyz.flipchat.app.features.chat.info.RoomInfoScreen +import xyz.flipchat.app.features.chat.list.RoomListScreen +import xyz.flipchat.app.features.chat.lookup.LookupRoomScreen +import xyz.flipchat.app.features.chat.name.RoomNameScreen +import xyz.flipchat.app.features.home.TabbedHomeScreen +import xyz.flipchat.app.features.login.LoginScreen +import xyz.flipchat.app.features.login.accesskey.AccessKeyScreen +import xyz.flipchat.app.features.login.accesskey.SeedInputScreen +import xyz.flipchat.app.features.login.permissions.NotificationPermissionScreen +import xyz.flipchat.app.features.login.register.AccessKeyModalScreen +import xyz.flipchat.app.features.login.register.PurchaseAccountScreen +import xyz.flipchat.app.features.login.register.RegisterInfoScreen +import xyz.flipchat.app.features.login.register.RegisterModalScreen +import xyz.flipchat.app.features.login.register.RegisterScreen +import xyz.flipchat.app.features.restricted.AppRestrictedScreen +import xyz.flipchat.app.features.settings.SettingsScreen + +@Composable +fun AppScreenContent(content: @Composable () -> Unit) { + ScreenRegistry { + register { + AppRestrictedScreen(it.restrictionType) + } + + register { + LoginScreen(it.seed) + } + + register { + RegisterInfoScreen() + } + + register { + if (it.showInModal) { + RegisterModalScreen() + } else { + RegisterScreen() + } + } + + register { + if (it.showInModal) { + AccessKeyModalScreen() + } else { + AccessKeyScreen() + } + } + + register { + PurchaseAccountScreen() + } + + register { + NotificationPermissionScreen(it.fromOnboarding) + } + + register { + SeedInputScreen + } + + register { + BalanceScreen() + } + + register { + TabbedHomeScreen(it.deeplink) + } + + register { + RoomListScreen() + } + + register { + LookupRoomScreen() + } + + register { + ConversationScreen( + chatId = it.chatId, + roomNumber = it.roomNumber, + ) + } + + register { + RoomInfoScreen(it.args, false, it.returnToSender) + } + + register { + RoomInfoScreen(it.args, true, it.returnToSender) + } + + register { + CoverChargeScreen(it.id) + } + + register { + RoomNameScreen(it.id, it.title) + } + + register { + SettingsScreen() + } + + register { + BetaFlagsScreen() + } + } + content() +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/ui/navigation/MainRoot.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/ui/navigation/MainRoot.kt new file mode 100644 index 000000000..a55b830b8 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/ui/navigation/MainRoot.kt @@ -0,0 +1,131 @@ +package xyz.flipchat.app.ui.navigation + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.size +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import xyz.flipchat.app.R +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.extensions.getActivityScopedViewModel +import xyz.flipchat.app.ui.LocalUserManager +import com.getcode.theme.CodeTheme +import com.getcode.theme.White +import com.getcode.ui.theme.CodeCircularProgressIndicator +import dev.theolm.rinku.DeepLink +import dev.theolm.rinku.compose.ext.DeepLinkListener +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import timber.log.Timber +import xyz.flipchat.app.features.home.HomeViewModel +import xyz.flipchat.services.user.AuthState + +internal class MainRoot(private val deepLink: () -> DeepLink?) : Screen { + + override val key: ScreenKey = uniqueScreenKey + + private fun readResolve(): Any = this + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val userManager = LocalUserManager.currentOrThrow + var showLoading by remember { mutableStateOf(false) } + val homeViewModel = getActivityScopedViewModel() + val router = homeViewModel.router + + Box( + modifier = Modifier + .fillMaxSize() + .background(CodeTheme.colors.secondary), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .fillMaxWidth(0.65f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(R.drawable.flipchat_logo), + contentDescription = null, + ) + } + + Spacer(modifier = Modifier.requiredHeight(CodeTheme.dimens.inset)) + val loadingAlpha by animateFloatAsState( + if (showLoading) 1f else 0f, + label = "loading visibility" + ) + CodeCircularProgressIndicator( + modifier = Modifier.alpha(loadingAlpha) + ) + } + } + + + LaunchedEffect(userManager) { + userManager.state + .map { it.authState } + .distinctUntilChanged() + .onEach { state -> + Timber.d("sessionState=$state") + when (state) { + AuthState.LoggedInAwaitingUser -> { + delay(500) + showLoading = true + } + AuthState.Unregistered, + AuthState.LoggedIn -> { + val screens = router.processDestination(deepLink()) + + if (screens.isNotEmpty()) { + navigator.replaceAll(screens) + } else { + navigator.replace(ScreenRegistry.get(NavScreenProvider.AppHomeScreen())) + } + } + AuthState.LoggedOut -> { + navigator.replace(ScreenRegistry.get(NavScreenProvider.Login.Home())) + } + AuthState.Unknown -> { + navigator.replace(ScreenRegistry.get(NavScreenProvider.Login.Home())) + } + } + }.launchIn(this) + } + } +} + diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/ui/room/RoomCard.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/ui/room/RoomCard.kt new file mode 100644 index 000000000..7b00b3f0b --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/ui/room/RoomCard.kt @@ -0,0 +1,217 @@ +package xyz.flipchat.app.ui.room + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.getcode.model.Currency +import xyz.flipchat.app.R +import xyz.flipchat.app.theme.FlipchatTheme +import com.getcode.theme.CodeTheme +import com.getcode.theme.dropShadow +import com.getcode.ui.utils.ConstraintMode +import com.getcode.ui.utils.Geometry +import com.getcode.ui.utils.constrain +import com.getcode.ui.utils.measured +import com.getcode.ui.utils.withDropShadow +import com.getcode.util.resources.LocalResources +import com.getcode.utils.Kin +import com.getcode.utils.decodeBase58 +import com.getcode.utils.formatAmountString +import xyz.flipchat.app.data.RoomInfo + + +private class RoomCardGeometry(width: Dp, height: Dp) : Geometry(width, height) { + + val topSpacer: Dp + get() = size.height * 0.06f + + val iconWidth: Dp + get() = size.width * 0.2f + + val iconHeight: Dp + get() = size.width * 0.2f + + val titleTopPadding: Dp + get() = size.height * 0.2f + + val titleBottomPadding: Dp + get() = size.height * 0.2f + + val bottomSpacer: Dp + get() = size.height * 0.1f +} + + +@Composable +fun RoomCard( + modifier: Modifier = Modifier, + roomInfo: RoomInfo, +) { + Box( + modifier = modifier + .dropShadow(blurRadius = 40.dp, color = Color.Black.copy(alpha = 0.30f)) + .aspectRatio(0.65f) + .clip(CodeTheme.shapes.small) + .background(Color(0xFFD9D9D9)) + .background( + brush = Brush.verticalGradient( + colorStops = arrayOf( + 0.14f to roomInfo.gradientColors.first, + 0.38f to roomInfo.gradientColors.second, + 0.67f to roomInfo.gradientColors.third, + ), + ), + ) + .background( + brush = Brush.radialGradient( + colors = listOf(Color.White.copy(0.65f), Color.Transparent), + center = Offset(-200f, -200f), + radius = 1800f + ), + ), + contentAlignment = Alignment.Center + ) { + BoxWithConstraints { + val maxWidth = maxWidth + val geometry = RoomCardGeometry(maxWidth, maxHeight) + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.requiredHeight(geometry.topSpacer)) + Image( + modifier = Modifier + .size( + geometry.iconWidth, + geometry.iconHeight + ), + painter = painterResource(R.drawable.flipchat_logo), + contentDescription = null + ) + Spacer(Modifier.requiredHeight(geometry.titleTopPadding)) + + val titleStyle = CodeTheme.typography.displaySmall + var textSize by remember { mutableStateOf(titleStyle.fontSize) } + var titleSize by remember { mutableStateOf(DpSize.Zero) } + + Box(modifier = Modifier.measured { titleSize = it }) { + Text( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.grid.x4) + .constrain( + mode = ConstraintMode.AutoSize(CodeTheme.typography.displaySmall), + text = roomInfo.title, + style = CodeTheme.typography.displaySmall.copy(textAlign = TextAlign.Center), + frameConstraints = Constraints( + maxWidth = with(LocalDensity.current) { maxWidth.roundToPx() }, + maxHeight = with(LocalDensity.current) { titleSize.height.roundToPx() }, + ) + ) { textSize = it }, + text = roomInfo.title, + style = CodeTheme.typography.displaySmall + .copy(fontSize = textSize, lineHeight = 24.sp, textAlign = TextAlign.Center) + .withDropShadow(), + color = Color.White, + maxLines = 3 + ) + } + Spacer(Modifier.requiredHeight(geometry.titleBottomPadding)) + Column( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.grid.x6), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (roomInfo.hostName != null) { + Text( + text = stringResource( + R.string.title_roomCardHostedBy, + roomInfo.hostName + ), + style = CodeTheme.typography.textSmall.withDropShadow(), + color = Color.White.copy(0.80f) + ) + } + Text( + text = pluralStringResource( + R.plurals.title_roomCardMemberCount, + roomInfo.memberCount, + roomInfo.memberCount + ), + style = CodeTheme.typography.textSmall.withDropShadow(), + color = Color.White.copy(0.80f) + ) + Text( + text = stringResource( + R.string.title_roomCardJoinCost, + formatAmountString( + resources = LocalResources.current!!, + currency = Currency.Kin, + amount = roomInfo.messagingFee.quarks.toDouble(), + suffix = stringResource(R.string.core_kin) + ) + ), + textAlign = TextAlign.Center, + style = CodeTheme.typography.textSmall.withDropShadow(), + color = Color.White.copy(0.80f) + ) + } + Spacer(Modifier.requiredHeight(geometry.bottomSpacer)) + } + } + } +} + +val id = "4T7DtS9CEZKVJrBgujQLcjBYnMqZSzZV6CqJewME6zVp".decodeBase58().toList() + +@Preview +@Composable +private fun Preview_RoomCard() { + FlipchatTheme { + Box(modifier = Modifier.size(375.dp, 812.dp)) { + RoomCard( + modifier = Modifier.align(Alignment.Center), + roomInfo = RoomInfo( + id = id, + title = "Room #237", + hostName = "Ivy", + memberCount = 24, + roomNumber = 0 + ) + ) + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/AccountAuthenticator.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/AccountAuthenticator.kt new file mode 100644 index 000000000..d379f44cb --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/AccountAuthenticator.kt @@ -0,0 +1,96 @@ +package xyz.flipchat.app.util + + +import android.accounts.AbstractAccountAuthenticator +import android.accounts.Account +import android.accounts.AccountAuthenticatorResponse +import android.accounts.NetworkErrorException +import android.content.Context +import android.os.Bundle +import androidx.core.os.bundleOf +import com.getcode.utils.trace +import android.accounts.AccountManager as AndroidAccountManager + + +class AccountAuthenticator( + private val context: Context, +) : AbstractAccountAuthenticator(context) { + @Throws(NetworkErrorException::class) + override fun addAccount( + response: AccountAuthenticatorResponse, + accountType: String, + authTokenType: String, + requiredFeatures: Array, + options: Bundle + ): Bundle = Bundle() + + @Throws(NetworkErrorException::class) + override fun confirmCredentials( + arg0: AccountAuthenticatorResponse, + arg1: Account, arg2: Bundle + ): Bundle? = null + + override fun editProperties(arg0: AccountAuthenticatorResponse, arg1: String): Bundle? = null + + @Throws(NetworkErrorException::class) + override fun getAuthToken( + response: AccountAuthenticatorResponse, + account: Account, + authTokenType: String, + options: Bundle + ): Bundle { + // Extract the username and password from the Account Manager, then, generate token + val am = AndroidAccountManager.get(context) + var authToken = am.peekAuthToken(account, authTokenType) + trace("authenticator: authToken ${authToken != null}, $authTokenType") + // Lets give another try to authenticate the user + if (null != authToken) { + if (authToken.isEmpty()) { + val password = am.getPassword(account) + if (password != null) { + authToken = "test" + } + } + } + + // If we get an authToken - we return it + if (null != authToken) { + if (authToken.isNotEmpty()) { + val result = Bundle() + result.putString(AndroidAccountManager.KEY_ACCOUNT_NAME, account.name) + result.putString(AndroidAccountManager.KEY_ACCOUNT_TYPE, account.type) + result.putString(AndroidAccountManager.KEY_AUTHTOKEN, authToken) + return result + } + } + + trace( + message = "authenticator failure", + error = Throwable("Failed to retrieve authToken from AccountManager") + ) + // If we get here, then we couldn't access the user's password + return Bundle() + } + + override fun getAuthTokenLabel(arg0: String): String? { + return "entropy" + } + + @Throws(NetworkErrorException::class) + override fun hasFeatures( + arg0: AccountAuthenticatorResponse, arg1: Account, + arg2: Array + ): Bundle { + // This call is used to query whether the Authenticator supports + // specific features. We don't expect to get called, so we always + // return false (no) for any queries. + val result = bundleOf(AndroidAccountManager.KEY_BOOLEAN_RESULT to false) + return result + } + + @Throws(NetworkErrorException::class) + override fun updateCredentials( + arg0: AccountAuthenticatorResponse, + arg1: Account, arg2: String, arg3: Bundle + ): Bundle? = null +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/AccountManager.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/AccountManager.kt new file mode 100644 index 000000000..1f0b0df15 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/AccountManager.kt @@ -0,0 +1,13 @@ +package xyz.flipchat.app.util + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class AccountManager @Inject constructor( + @ApplicationContext private val context: Context +) { + suspend fun getToken(): String? { + return AccountUtils.getToken(context)?.token + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/AccountUtils.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/AccountUtils.kt new file mode 100644 index 000000000..b760fd809 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/AccountUtils.kt @@ -0,0 +1,245 @@ +package xyz.flipchat.app.util + +import android.accounts.Account +import android.accounts.AccountManager +import android.accounts.AuthenticatorException +import android.content.Context +import android.os.Handler +import android.os.HandlerThread +import androidx.core.os.bundleOf +import com.getcode.utils.TraceType +import com.getcode.utils.network.retryable +import com.getcode.utils.trace +import io.reactivex.rxjava3.annotations.NonNull +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.subjects.SingleSubject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.datetime.Clock +import xyz.flipchat.app.BuildConfig +import java.util.Optional +import kotlin.coroutines.resume + + +object AccountUtils { + private const val ACCOUNT_TYPE = BuildConfig.APPLICATION_ID + private const val ACCOUNT_REGISTERED = "fc_account_registered" + private const val ACCOUNT_UNREGISTERED = "fc_account_unregistered" + + fun addAccount( + context: Context, + name: String, + password: String, + token: String, + isUnregistered: Boolean, + ) { + val accountManager: AccountManager = AccountManager.get(context) + val account = Account(name, ACCOUNT_TYPE) + + val data = bundleOf(AccountManager.KEY_AUTH_TOKEN_LABEL to if (isUnregistered) ACCOUNT_UNREGISTERED else ACCOUNT_REGISTERED) + accountManager.addAccountExplicitly(account, password, data) + accountManager.setAuthToken(account, ACCOUNT_TYPE, token) + } + + suspend fun removeAccounts(context: Context): @NonNull Single { + return getAccount(context) + .map { + if (it.second == null) return@map false + val am: AccountManager = AccountManager.get(context) + am.removeAccountExplicitly(it.second) + } + } + + suspend fun updateAccount(context: Context, name: String): Result { + return runCatching { + val account = getAccount(context) + .mapOptional { + Optional.ofNullable(it.second) + }.blockingGet() ?: throw Throwable("Unable to get account") + val am: AccountManager = AccountManager.get(context) + + suspendCancellableCoroutine { cont -> + am.renameAccount(/* account = */ account, + /* newName = */ name, + /* callback = */ { future -> + try { + val bundle = future?.result + val updated = bundle?.name == name + if (updated) { + cont.resume(Unit) + } else { + cont.resumeWith(Result.failure(Throwable("Failed to update name"))) + } + } catch (e: AuthenticatorException) { + e.printStackTrace() + cont.resumeWith(Result.failure(e)) + } + }, + /* handler = */ handler + ) + } + } + } + + private suspend fun getAccount(context: Context): @NonNull Single> { + val subject = SingleSubject.create>() + return subject.doOnSubscribe { + CoroutineScope(Dispatchers.IO).launch { + val result = retryable( + call = { getAccountNoActivity(context) }, + onRetry = { currentAttempt -> + trace( + tag = "Account", + message = "Retrying call", + metadata = { + "count" to currentAttempt + }, + type = TraceType.Process, + ) + } + ) + subject.onSuccess(result ?: (null to null)) + } + } + } + + private val handler: Handler by lazy { Handler(handlerThread.looper) } + + private val handlerThread: HandlerThread by lazy { + HandlerThread("RenetikBackgroundThread").apply { + setUncaughtExceptionHandler { _, e -> run { throw RuntimeException(e) } } + start() + } + } + + private suspend fun getAccountNoActivity( + context: Context + ): Pair? = suspendCancellableCoroutine { cont -> + trace("getAuthToken", type = TraceType.Silent) + val am: AccountManager = AccountManager.get(context) + val account = am.getAccountsByType(ACCOUNT_TYPE).firstOrNull() + if (account == null) { + trace( + "no associated account found", + type = TraceType.Error + ) + cont.resume(null) + return@suspendCancellableCoroutine + } + val start = Clock.System.now() + am.getAuthToken( + account, ACCOUNT_TYPE, null, false, + { future -> + try { + val bundle = future?.result + val authToken = bundle?.getString(AccountManager.KEY_AUTHTOKEN) + + val end = Clock.System.now() + trace("auth token fetch took ${end.toEpochMilliseconds() - start.toEpochMilliseconds()} ms") + + cont.resume(authToken.orEmpty() to account) + } catch (e: AuthenticatorException) { + trace( + message = "failed to read account", + error = e, + type = TraceType.Error + ) + cont.resume(null) + } + }, handler + ) + } + + suspend fun getUserId(context: Context): UserIdResult? { + val accountManager = AccountManager.get(context) + val accounts = accountManager.getAccountsByType(ACCOUNT_TYPE) + fun getPassword(a: Account?): String? { + if (a != null) { + val pw = runCatching { accountManager.getPassword(a) } + .getOrNull()?.takeIf { it.isNotEmpty() } + if (pw != null) { + return pw + } + } + return null + } + + val account = accounts.firstOrNull() + + val label = account?.let { accountManager.getUserData(it, AccountManager.KEY_AUTH_TOKEN_LABEL) } + + getPassword(account)?.let { + if (label == ACCOUNT_UNREGISTERED) { + return UserIdResult.Unregistered(it) + } else { + return UserIdResult.Registered(it) + } + } + + val (_, acct) = retryable( + call = { getAccountNoActivity(context) }, + onRetry = { currentAttempt -> + trace( + tag = "Account", + message = "Retrying call", + metadata = { + "count" to currentAttempt + }, + type = TraceType.Process, + ) + } + ) ?: return null + + return getPassword(acct)?.let { + if (label == ACCOUNT_UNREGISTERED) { + UserIdResult.Unregistered(it) + } else { + UserIdResult.Registered(it) + } + } + } + + suspend fun getToken(context: Context): TokenResult? { + val accountManager = AccountManager.get(context) + val accounts = accountManager.getAccountsByType(ACCOUNT_TYPE) + val account = accounts.firstOrNull() + if (account != null) { + val token = runCatching { accountManager.peekAuthToken(account, ACCOUNT_TYPE) } + .getOrNull()?.takeIf { it.isNotEmpty() } + if (token != null) { + return TokenResult.Account(token) + } + } + + val token = retryable( + call = { getAccountNoActivity(context) }, + onRetry = { currentAttempt -> + trace( + tag = "Account", + message = "Retrying call", + metadata = { + "count" to currentAttempt + }, + type = TraceType.Process, + ) + } + + )?.first ?: return null + + return TokenResult.Account(token) + } +} + +sealed interface TokenResult { + val token: String + + data class Account(override val token: String) : TokenResult + data class Code(override val token: String) : TokenResult +} + +sealed interface UserIdResult { + data class Registered(val userId: String): UserIdResult + data class Unregistered(val userId: String): UserIdResult +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/AndroidLocale.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/AndroidLocale.kt new file mode 100644 index 000000000..126b02c2e --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/AndroidLocale.kt @@ -0,0 +1,25 @@ +package xyz.flipchat.app.util + +import android.content.Context +import com.getcode.model.Currency +import com.getcode.util.locale.LocaleHelper +import com.getcode.util.locale.LocaleUtils +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class AndroidLocale @Inject constructor( + @ApplicationContext private val context: Context, + private val currencyUtils: com.getcode.utils.CurrencyUtils, +): LocaleHelper { + override fun getDefaultCurrencyName(): String { + return LocaleUtils.getDefaultCurrency(context) + } + + override fun getDefaultCurrency(): Currency? { + return currencyUtils.getCurrency(getDefaultCurrencyName()) + } + + override fun getDefaultCountry(): String { + return LocaleUtils.getDefaultCountry(context) + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/AuthenticatorService.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/AuthenticatorService.kt new file mode 100644 index 000000000..24f45e657 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/AuthenticatorService.kt @@ -0,0 +1,23 @@ +package xyz.flipchat.app.util + +import android.accounts.AccountManager +import android.app.Service +import android.content.Intent +import android.os.IBinder +import dagger.hilt.android.AndroidEntryPoint + + +@AndroidEntryPoint +class AuthenticatorService : Service() { + private val accountAuthenticator: AccountAuthenticator by lazy { + AccountAuthenticator(this) + } + + override fun onBind(intent: Intent): IBinder? { + var binder: IBinder? = null + if (intent.action == AccountManager.ACTION_AUTHENTICATOR_INTENT) { + binder = accountAuthenticator.iBinder + } + return binder + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/Bitmap.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/Bitmap.kt new file mode 100644 index 000000000..9efff6490 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/Bitmap.kt @@ -0,0 +1,29 @@ +package xyz.flipchat.app.util + +import android.graphics.Bitmap +import com.getcode.utils.ErrorUtils +import com.getcode.utils.timedTrace +import java.io.File +import java.io.FileOutputStream + +internal fun Bitmap.save(destination: File, name: () -> String): Boolean { + val filename = name() + if (!destination.exists()) { + if (!destination.mkdirs()) { + return false + } + } + val dest = File(destination, filename) + + return timedTrace("saving bitmap") { + try { + FileOutputStream(dest).use { out -> + compress(Bitmap.CompressFormat.PNG, 90, out) + } + } catch (e: Exception) { + ErrorUtils.handleError(e) + return@timedTrace false + } + return@timedTrace true + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/ChromeTabsUtils.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/ChromeTabsUtils.kt new file mode 100644 index 000000000..2e31886c4 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/ChromeTabsUtils.kt @@ -0,0 +1,72 @@ +package xyz.flipchat.app.util + +import android.content.ComponentName +import android.content.Context +import android.net.Uri +import androidx.browser.customtabs.CustomTabColorSchemeParams +import androidx.browser.customtabs.CustomTabsClient +import androidx.browser.customtabs.CustomTabsIntent +import androidx.browser.customtabs.CustomTabsServiceConnection +import androidx.browser.customtabs.CustomTabsSession +import androidx.compose.ui.graphics.Color +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import xyz.flipchat.app.R +import com.getcode.theme.Brand +import com.getcode.ui.utils.toAGColor + +object ChromeTabsUtils { + fun launchUrl( + context: Context, + url: String, + showBack: Boolean = false + ) { + val mCustomTabsServiceConnection: CustomTabsServiceConnection? + var mClient: CustomTabsClient? + var mCustomTabsSession: CustomTabsSession? = null + mCustomTabsServiceConnection = object : CustomTabsServiceConnection() { + override fun onCustomTabsServiceConnected( + componentName: ComponentName, + customTabsClient: CustomTabsClient + ) { + mClient = customTabsClient + mClient?.warmup(0L) + mCustomTabsSession = mClient?.newSession(null) + } + + override fun onServiceDisconnected(name: ComponentName) { + mClient = null + } + } + CustomTabsClient.bindCustomTabsService( + context, + "com.android.chrome", + mCustomTabsServiceConnection + ) + val builder: CustomTabsIntent.Builder = CustomTabsIntent.Builder(mCustomTabsSession) + .setShowTitle(false) + .setShareState(CustomTabsIntent.SHARE_STATE_OFF) + .setInstantAppsEnabled(false) + .setColorSchemeParams( + CustomTabsIntent.COLOR_SCHEME_DARK, + CustomTabColorSchemeParams.Builder() + .setToolbarColor(Brand.toAGColor()) + .setNavigationBarDividerColor(Color.Transparent.toAGColor()) + .setNavigationBarColor(Color.Transparent.toAGColor()) + .build() + ) + + if (showBack) { + ContextCompat.getDrawable( + context, + R.drawable.ic_arrow_back + )?.toBitmap()?.let { backArrow -> + builder.setCloseButtonIcon(backArrow) + } + } + + val customTabsIntent = builder.build() + + customTabsIntent.launchUrl(context, Uri.parse(url)) + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/Context.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/Context.kt new file mode 100644 index 000000000..884e8f3cb --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/Context.kt @@ -0,0 +1,14 @@ +package xyz.flipchat.app.util + +import android.content.Context +import androidx.core.content.ContextCompat + +fun Context.launchAppSettings() { + val intent = IntentUtils.appSettings() + ContextCompat.startActivity(this, intent, null) +} + +fun Context.dialNumber(number: String) { + val intent = IntentUtils.dialNumber(number) + ContextCompat.startActivity(this, intent, null) +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/IntentUtils.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/IntentUtils.kt new file mode 100644 index 000000000..560f648a0 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/IntentUtils.kt @@ -0,0 +1,36 @@ +package xyz.flipchat.app.util + +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import xyz.flipchat.app.BuildConfig + + +object IntentUtils { + + fun appSettings() = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", BuildConfig.APPLICATION_ID, null) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + fun dialNumber(number: String) = Intent(Intent.ACTION_DIAL).apply { + data = Uri.parse("tel:$number") + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + fun shareRoom(roomNumber: Long): Intent { + val shareLink = "https://app.flipchat.xyz/room/$roomNumber" + + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, shareLink) + type = "text/plain" + } + + val shareIntent = Intent.createChooser(sendIntent, null).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + return shareIntent + } +} diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/Router.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/Router.kt new file mode 100644 index 000000000..9e10015a2 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/Router.kt @@ -0,0 +1,186 @@ +package xyz.flipchat.app.util + +import androidx.core.net.toUri +import cafe.adriel.voyager.core.registry.ScreenRegistry +import cafe.adriel.voyager.core.screen.Screen +import com.getcode.model.ID +import com.getcode.navigation.NavScreenProvider +import com.getcode.navigation.RoomInfoArgs +import com.getcode.navigation.screens.ChildNavTab +import com.getcode.util.resources.ResourceHelper +import com.getcode.vendor.Base58 +import dev.theolm.rinku.DeepLink +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import xyz.flipchat.app.features.home.tabs.CashTab +import xyz.flipchat.app.features.home.tabs.ChatTab +import xyz.flipchat.app.features.home.tabs.SettingsTab +import xyz.flipchat.controllers.ChatsController +import xyz.flipchat.internal.db.FcAppDatabase +import xyz.flipchat.services.extensions.titleOrFallback +import xyz.flipchat.services.user.UserManager + +interface Router { + fun checkTabs() + val rootTabs: List + fun getInitialTabIndex(deeplink: DeepLink?): Int + suspend fun processDestination(deeplink: DeepLink?): List + fun processType(deeplink: DeepLink?): DeeplinkType? + fun tabForIndex(index: Int): FcTab +} + +enum class FcTab { + Chat, Cash, Settings +} + +sealed interface DeeplinkType { + data class Login(val entropy: String) : DeeplinkType + data class OpenRoomByNumber(val number: Long, val messageId: ID? = null) : DeeplinkType +} + +class RouterImpl( + private val userManager: UserManager, + private val chatsController: ChatsController, + private val resources: ResourceHelper, + private val tabIndexResolver: (FcTab) -> Int, + private val indexTabResolver: (Int) -> FcTab, +) : Router, CoroutineScope by CoroutineScope(Dispatchers.IO) { + companion object { + val chats = listOf("chats") + val cash = listOf("cash") + val settings = listOf("settings") + + val login = listOf("login") + val room = listOf("room") + } + + private val db: FcAppDatabase + get() = FcAppDatabase.requireInstance() + + override fun tabForIndex(index: Int) = indexTabResolver(index) + + private val commonTabs = listOf(ChatTab, CashTab) + private val tabs = MutableStateFlow(commonTabs) + + override fun checkTabs() { + launch { + tabs.value = userManager.state + .map { it.flags } + .map { + if (it?.isStaff == true) { + commonTabs + SettingsTab + } else { + commonTabs + } + }.firstOrNull() ?: commonTabs + } + } + + override val rootTabs: List + get() = tabs.value + + override fun getInitialTabIndex(deeplink: DeepLink?): Int { + return deeplink?.let { + when { + deeplink.pathSegments.isEmpty() -> tabIndexResolver(FcTab.Chat) + chats.contains(deeplink.pathSegments[0]) -> tabIndexResolver(FcTab.Chat) + room.contains(deeplink.pathSegments[0]) -> tabIndexResolver(FcTab.Chat) + cash.contains(deeplink.pathSegments[0]) -> tabIndexResolver(FcTab.Cash) + settings.contains(deeplink.pathSegments[0]) -> tabIndexResolver(FcTab.Settings) + else -> 0 + } + } ?: 0 + } + + override suspend fun processDestination(deeplink: DeepLink?): List { + return deeplink?.let { + val type = processType(deeplink) ?: return emptyList() + when (type) { + is DeeplinkType.Login -> listOf(ScreenRegistry.get(NavScreenProvider.AppHomeScreen(deeplink))) + + is DeeplinkType.OpenRoomByNumber -> { + val conversation = db.conversationDao().findConversationRaw(type.number) + val screens = mutableListOf(ScreenRegistry.get(NavScreenProvider.AppHomeScreen())) + if (conversation != null) { + screens.add(ScreenRegistry.get(NavScreenProvider.Room.Messages(conversation.id))) + } else { + val lookup = chatsController.lookupRoom(type.number).getOrNull() + if (lookup != null) { + val (room, members) = lookup + val moderator = members.firstOrNull { it.isModerator } + + val args = RoomInfoArgs( + roomId = room.id, + roomNumber = room.roomNumber, + roomTitle = room.titleOrFallback(resources,), + memberCount = members.count(), + ownerId = room.ownerId, + hostName = moderator?.identity?.displayName, + messagingFeeQuarks = room.messagingFee.quarks, + ) + + screens.add( + ScreenRegistry.get( + NavScreenProvider.Room.Preview(args = args, returnToSender = true) + ) + ) + } + } + + screens + } + } + } ?: emptyList() + } + + override fun processType(deeplink: DeepLink?): DeeplinkType? { + return deeplink?.let { + when (deeplink.pathSegments.size) { + 1 -> { + when { + login.contains(deeplink.pathSegments[0]) -> { + var entropy = runCatching { + deeplink.data.toUri().getQueryParameter("data") + }.getOrNull() + + // if not found at data check `e` + if (entropy == null) { + entropy = runCatching { + deeplink.data.toUri().getQueryParameter("e") + }.getOrNull() ?: return null + } + + DeeplinkType.Login(entropy) + } + + else -> null + } + } + + 2 -> { + when { + room.contains(deeplink.pathSegments[0]) -> { + val number = runCatching { + deeplink.pathSegments[1].toLongOrNull() + }.getOrNull() ?: return null + + val messageId = runCatching { + deeplink.data.toUri().getQueryParameter("m")?.let { Base58.decode(it).toList() } + }.getOrNull() + + DeeplinkType.OpenRoomByNumber(number = number, messageId = messageId) + } + + else -> null + } + } + + else -> null + } + } + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/media/MediaScanner.kt b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/media/MediaScanner.kt new file mode 100644 index 000000000..d9e0aa950 --- /dev/null +++ b/flipchatApp/src/main/kotlin/xyz/flipchat/app/util/media/MediaScanner.kt @@ -0,0 +1,15 @@ +package xyz.flipchat.app.util.media + +import android.content.Context +import android.media.MediaScannerConnection +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject + +class MediaScanner @Inject constructor( + @ApplicationContext private val context: Context +) { + fun scan(directory: File) { + MediaScannerConnection.scanFile(context, arrayOf(directory.toString()), null, null) + } +} \ No newline at end of file diff --git a/flipchatApp/src/main/res/drawable-nodpi/ic_access_key_bg.webp b/flipchatApp/src/main/res/drawable-nodpi/ic_access_key_bg.webp new file mode 100644 index 000000000..dbf2ae661 Binary files /dev/null and b/flipchatApp/src/main/res/drawable-nodpi/ic_access_key_bg.webp differ diff --git a/flipchatApp/src/main/res/drawable/flipchat_logo.xml b/flipchatApp/src/main/res/drawable/flipchat_logo.xml new file mode 100644 index 000000000..c7288771c --- /dev/null +++ b/flipchatApp/src/main/res/drawable/flipchat_logo.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + diff --git a/flipchatApp/src/main/res/drawable/ic_door_open.xml b/flipchatApp/src/main/res/drawable/ic_door_open.xml new file mode 100644 index 000000000..17f9ad764 --- /dev/null +++ b/flipchatApp/src/main/res/drawable/ic_door_open.xml @@ -0,0 +1,9 @@ + + + diff --git a/flipchatApp/src/main/res/drawable/ic_fc_balance.xml b/flipchatApp/src/main/res/drawable/ic_fc_balance.xml new file mode 100644 index 000000000..367c69b08 --- /dev/null +++ b/flipchatApp/src/main/res/drawable/ic_fc_balance.xml @@ -0,0 +1,10 @@ + + + diff --git a/flipchatApp/src/main/res/drawable/ic_fc_chats.xml b/flipchatApp/src/main/res/drawable/ic_fc_chats.xml new file mode 100644 index 000000000..88e02a31e --- /dev/null +++ b/flipchatApp/src/main/res/drawable/ic_fc_chats.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/flipchatApp/src/main/res/drawable/ic_flipchat_logo_access_key.xml b/flipchatApp/src/main/res/drawable/ic_flipchat_logo_access_key.xml new file mode 100644 index 000000000..2bb9c7197 --- /dev/null +++ b/flipchatApp/src/main/res/drawable/ic_flipchat_logo_access_key.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + diff --git a/flipchatApp/src/main/res/drawable/ic_flipchat_notification.xml b/flipchatApp/src/main/res/drawable/ic_flipchat_notification.xml new file mode 100644 index 000000000..ed7023765 --- /dev/null +++ b/flipchatApp/src/main/res/drawable/ic_flipchat_notification.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + diff --git a/flipchatApp/src/main/res/drawable/ic_launcher_foreground.xml b/flipchatApp/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..77fc00c44 --- /dev/null +++ b/flipchatApp/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + diff --git a/flipchatApp/src/main/res/drawable/ic_notification_request.png b/flipchatApp/src/main/res/drawable/ic_notification_request.png new file mode 100644 index 000000000..bb845f710 Binary files /dev/null and b/flipchatApp/src/main/res/drawable/ic_notification_request.png differ diff --git a/flipchatApp/src/main/res/drawable/ic_reply.xml b/flipchatApp/src/main/res/drawable/ic_reply.xml new file mode 100644 index 000000000..f5f525d45 --- /dev/null +++ b/flipchatApp/src/main/res/drawable/ic_reply.xml @@ -0,0 +1,9 @@ + + + diff --git a/flipchatApp/src/main/res/drawable/splash.xml b/flipchatApp/src/main/res/drawable/splash.xml new file mode 100644 index 000000000..1cc928369 --- /dev/null +++ b/flipchatApp/src/main/res/drawable/splash.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/flipchatApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/flipchatApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..7353dbd1f --- /dev/null +++ b/flipchatApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/flipchatApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/flipchatApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..7353dbd1f --- /dev/null +++ b/flipchatApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/flipchatApp/src/main/res/mipmap-hdpi/ic_launcher.webp b/flipchatApp/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 000000000..e8655fc13 Binary files /dev/null and b/flipchatApp/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/flipchatApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/flipchatApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 000000000..2704887ed Binary files /dev/null and b/flipchatApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/flipchatApp/src/main/res/mipmap-mdpi/ic_launcher.webp b/flipchatApp/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 000000000..8b153e243 Binary files /dev/null and b/flipchatApp/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/flipchatApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/flipchatApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 000000000..2ee000bbe Binary files /dev/null and b/flipchatApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/flipchatApp/src/main/res/mipmap-xhdpi/ic_launcher.webp b/flipchatApp/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 000000000..c4f441b88 Binary files /dev/null and b/flipchatApp/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/flipchatApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/flipchatApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..b7cda1902 Binary files /dev/null and b/flipchatApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/flipchatApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/flipchatApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 000000000..0cba69e0f Binary files /dev/null and b/flipchatApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/flipchatApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/flipchatApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..db85d88a2 Binary files /dev/null and b/flipchatApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/flipchatApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/flipchatApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 000000000..5214661e0 Binary files /dev/null and b/flipchatApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/flipchatApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/flipchatApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..e9ad5d50e Binary files /dev/null and b/flipchatApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/flipchatApp/src/main/res/values/colors.xml b/flipchatApp/src/main/res/values/colors.xml new file mode 100644 index 000000000..1d56a948f --- /dev/null +++ b/flipchatApp/src/main/res/values/colors.xml @@ -0,0 +1,34 @@ + + + + #FF8383 + #FFA1A1 + #FFF383 + #FFF6A3 + #FFF6A3 + #65F57C + + + #666666 + #8D8D94 + #BABBC2 + #E6E6EB + #F0F0F5 + + + #0F0C1F + #7379A0 + #565C86 + #443091 + + #0F0C1F + #8785A9 + #66000000 + #B3000000 + #00000000 + #1D1A30 + + #FF000000 + #FFFFFFFF + #05FFFFFF + \ No newline at end of file diff --git a/flipchatApp/src/main/res/values/ic_launcher_background.xml b/flipchatApp/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..9493af536 --- /dev/null +++ b/flipchatApp/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #443091 + \ No newline at end of file diff --git a/flipchatApp/src/main/res/values/plurals.xml b/flipchatApp/src/main/res/values/plurals.xml new file mode 100644 index 000000000..541af4858 --- /dev/null +++ b/flipchatApp/src/main/res/values/plurals.xml @@ -0,0 +1,29 @@ + + + + 0 Speakers + 1 Speaker + %d Speakers + + @string/title_roomInfoSpeakerCountEmpty + @string/title_roomInfoSpeakerCountSingle + @string/title_roomInfoSpeakerCountMany + @string/title_roomInfoSpeakerCountMany + @string/title_roomInfoSpeakerCountMany + @string/title_roomInfoSpeakerCountMany + + + 0 Listeners + 1 Listener + %d Listeners + + @string/title_roomInfoListenerCountEmpty + @string/title_roomInfoListenerCountSingle + @string/title_roomInfoListenerCountMany + @string/title_roomInfoListenerCountMany + @string/title_roomInfoListenerCountMany + @string/title_roomInfoListenerCountMany + + + + \ No newline at end of file diff --git a/flipchatApp/src/main/res/values/strings-universal.xml b/flipchatApp/src/main/res/values/strings-universal.xml new file mode 100644 index 000000000..507e2f760 --- /dev/null +++ b/flipchatApp/src/main/res/values/strings-universal.xml @@ -0,0 +1,11 @@ + + + Flipchat + Flipchat + \@flipchatapp + FlipchatAccount + + https://app.flipchat.xyz + https://flipchat.xyz/terms + https://flipchat.xyz/privacy + \ No newline at end of file diff --git a/flipchatApp/src/main/res/values/strings.xml b/flipchatApp/src/main/res/values/strings.xml new file mode 100644 index 000000000..254a32ce5 --- /dev/null +++ b/flipchatApp/src/main/res/values/strings.xml @@ -0,0 +1,249 @@ + + + Your Name + Flipchat Name + + Nevermind + + Get Started + + By tapping any button above you + + Send %1$s to Start Chatting + Loading your balance and transaction history + Loading your chats + Join Flipchats + Start Listening + Find a Flipchat + Chat + + Speakers + Listeners + Listener + None yet + + Something Went Wrong + You were unable to create a new Flipchat at this time. Please try again later + + Flipchat Doesn\’t Exist Yet + Please try a different Flipchat number + + Something Went Wrong + This user could not be made a speaker. Please check your network connection and try again + + Something Went Wrong + This user could not be removed as a speaker. Please check your network connection and try again + + Something Went Wrong + Your new message fee couldn\’t be saved. Please check your network connection and try again + + Something Went Wrong + You were unable to join %1$s at this time. Please try again later + + Something Went Wrong + You were unable to follow %1$s at this time. Please try again later + + Something Went Wrong + You were unable to get payment information at this time. Please try again later + + Something Went Wrong + You were unable to leave the room at this time. Please try again later + + Something Went Wrong + This message could not be deleted. Please check your network connection and try again + + Something Went Wrong + This user could not be removed. Please check your network connection and try again + + Something Went Wrong + This user\'s message could not be reported. Please check your network connection and try again + + Something Went Wrong + This user\'s could not be muted. Please check your network connection and try again + + Something Went Wrong + This user could not be block. Please check your network connection and try again + + Something Went Wrong + This user could not be unblocked. Please check your network connection and try again + + Something Went Wrong + Please check your network connection and try again + + Something Went Wrong + Please check your network connection and try again + + Something Went Wrong + We were unable to open this link. Please try again + + Purchase Failed + Something went wrong during payment. Please try again later + + Inappropriate Flipchat Name + Flipchat names need to be appropriate for all ages + + Something Went Wrong + We were unable to change the Flipchat name at this time. Please try again later + + Something Went Wrong + You were unable to reopen this Flipchat at this time. Please try again later + + Something Went Wrong + You were unable to close this Flipchat at this time. Please try again later + + Something Went Wrong + You were unable to send a tip at this time. Please try again later + + + Connect Your X + Identity in %1$s is based on your X identity. + %1$s I’d like to connect my X + Message %1$s to Connect + Balance + Flipchats + Settings + Enter Flipchat Number + Create a New Flipchat + Create a New Flipchat: ⬢ %1$s + Join a Flipchat + Enter Flipchat Number + Join Flipchat + Change Cover Charge + Change Listener Message Fee + + Join %1$s + Join Room: ⬢ %1$s + Pay to Chat: ⬢ %1$s + Send a Message: ⬢ %1$s + + Leave Flipchat? + You will need to pay to get back in, but we won\’t tell people you left + Leave %1$s + + Delete Message? + Their messages will be deleted for everyone + Delete + + Remove %1$s? + They will be able to rejoin after waiting an hour, but will have to pay the cover charge again + Remove + + Mute %1$s? + They will no longer be able send messages in this room + Mute + + Make %1$s a Speaker? + They will be able to message for free + Make a Speaker + + Remove %1$s as a Speaker? + They will no longer be able to message for free + Remove as Speaker + + Block %1$s? + All messages from this user will be hidden + Block + + Report + This message will be forwarded to Flipchat. This contact will not be notified + + Report Sent + Your report was sent successfully + + Receive push notifications when people message you. + + Hosted by %1$s + Cover Charge: ⬢ %1$s + + 0 People Inside + 1 Person Inside + %d People Inside + + @string/title_roomCardMemberCountEmpty + @string/title_roomCardMemberCountSingle + @string/title_roomCardMemberCountMany + @string/title_roomCardMemberCountMany + @string/title_roomCardMemberCountMany + @string/title_roomCardMemberCountMany + + + 0 people here + 1 person here + %d people here + + @string/title_conversationMemberCountEmpty + @string/title_conversationMemberCountSingle + @string/title_conversationMemberCountMany + @string/title_conversationMemberCountMany + @string/title_conversationMemberCountMany + @string/title_conversationMemberCountMany + + + Save + Save Changes + This is how you\’ll show up in chats + Enter a name appropriate for all ages + + You\'ve been muted + The host has temporarily closed this flipchat. Only they can send messages until they reopen it + Your Flipchat is currently open + Your Flipchat is currently closed + This Flipchat is currently open + This Flipchat is currently closed + Change + Reopen + Close + Reopen Flipchat? + People will be able to send messages again + Close Flipchat Temporarily + Reopen Flipchat + Close Flipchat Temporarily? + Only you will be able to send messages until you reopen the flipchat + Close Temporarily + Reopen Flipchat + + Your Access Key is the only way to access your account. Please keep it private and safe. + Warning! This image gives access to your Flipchat account. Do not share this image with anyone else. Keep it secure and safe. + Please allow Flipchat access to Photos in Settings in order to save your Access Key + + Access Key No Longer Usable in Flipchat + Your Access Key has initiated an unlock. As a result, you will no longer be able to use this Access Key in Flipchat + Not a Flipchat Account + Only accounts created through Flipchat are currently supported + Create a New Flipchat Account + Try a Different Flipchat Account + + Tap the Google Lens icon to open the QR code to log into Flipchat. Alternatively you can log in manually by entering the 12 words in the Flipchat Log In screen. + + Log Out? + You will need to enter your Access Key to get back into this account + + Labs + + Not Now + Create an Account to Join Flipchats + Purchase Your Account + New accounts cost %1$s + Finalize Account Creation + Accounts on Flipchat must be purchased for %1$s to reduce spam + Change Cover Charge + Change Listener Message Fee + Add Flipchat Name + Edit Flipchat Name + Change Flipchat Name + Customize + Customize Flipchat + Leave This Flipchat + Share a Link to Your Flipchat + Share a Link to This Flipchat + + Delete My Account + Permanently Delete Account? + This will permanently delete your Flipchat account + Permanently Delete My Account + + Balance: ⬢ %1$s + You + + We\'ve made some changes to improve the experience. You\'ll need to update the app to keep using Flipchat. + \ No newline at end of file diff --git a/flipchatApp/src/main/res/values/themes.xml b/flipchatApp/src/main/res/values/themes.xml new file mode 100644 index 000000000..aec3a4945 --- /dev/null +++ b/flipchatApp/src/main/res/values/themes.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/flipchatApp/src/main/res/xml/authenticator.xml b/flipchatApp/src/main/res/xml/authenticator.xml new file mode 100644 index 000000000..415269cbc --- /dev/null +++ b/flipchatApp/src/main/res/xml/authenticator.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 7623d34cb..ddf2cb530 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,4 +17,5 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official -android.nonTransitiveRClass=false \ No newline at end of file +android.nonTransitiveRClass=false +android.defaults.buildfeatures.usestaticrclass=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f02612bee..c6d7807f8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Wed May 08 12:51:12 EDT 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/libs/crypto/kin/.gitignore b/libs/crypto/kin/.gitignore new file mode 100644 index 000000000..9f2a07880 --- /dev/null +++ b/libs/crypto/kin/.gitignore @@ -0,0 +1,2 @@ +build/ +.gradle/ diff --git a/crypto/kin/build.gradle.kts b/libs/crypto/kin/build.gradle.kts similarity index 79% rename from crypto/kin/build.gradle.kts rename to libs/crypto/kin/build.gradle.kts index 72ac956c8..4a981af2a 100644 --- a/crypto/kin/build.gradle.kts +++ b/libs/crypto/kin/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } android { - namespace = "${Android.namespace}.vendor.kin" + namespace = "${Android.codeNamespace}.vendor.kin" compileSdk = Android.compileSdkVersion defaultConfig { minSdk = Android.minSdkVersion @@ -17,8 +17,6 @@ android { kotlinOptions { jvmTarget = Versions.java freeCompilerArgs += listOf( - "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", - "-Xuse-experimental=kotlinx.coroutines.FlowPreview", "-opt-in=kotlin.ExperimentalUnsignedTypes", "-opt-in=kotlin.RequiresOptIn" ) @@ -33,4 +31,4 @@ android { dependencies { api(Libs.kin_sdk) -} +} \ No newline at end of file diff --git a/api/.gitignore b/libs/crypto/solana/.gitignore similarity index 100% rename from api/.gitignore rename to libs/crypto/solana/.gitignore diff --git a/libs/crypto/solana/build.gradle.kts b/libs/crypto/solana/build.gradle.kts new file mode 100644 index 000000000..feab7e348 --- /dev/null +++ b/libs/crypto/solana/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.vendor.solana" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } +} + +dependencies { + implementation(project(":libs:encryption:base58")) + implementation(project(":libs:encryption:ed25519")) + implementation(project(":libs:encryption:hmac")) + implementation(project(":libs:encryption:keys")) + implementation(project(":libs:encryption:sha256")) + implementation(project(":libs:encryption:sha512")) + implementation(project(":libs:encryption:utils")) + implementation(project(":libs:crypto:kin")) + implementation(project(":libs:currency")) +// implementation(project(":libs:models")) + implementation(Libs.timber) + implementation(Libs.kotlinx_serialization_json) +} diff --git a/api/src/main/java/com/getcode/solana/AgoraMemo.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/AgoraMemo.kt similarity index 98% rename from api/src/main/java/com/getcode/solana/AgoraMemo.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/AgoraMemo.kt index a881081ab..1b90f5a2e 100644 --- a/api/src/main/java/com/getcode/solana/AgoraMemo.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/AgoraMemo.kt @@ -1,8 +1,8 @@ package com.getcode.solana -import com.getcode.network.repository.decodeBase64 -import com.getcode.network.repository.encodeBase64ToArray import com.getcode.solana.AgoraMemo.Companion.maxMagicByteIndicatorSize +import com.getcode.utils.decodeBase64 +import com.getcode.utils.encodeBase64ToArray import org.kin.sdk.base.tools.byteArrayToInt import org.kin.sdk.base.tools.shl import org.kin.sdk.base.tools.subByteArray diff --git a/api/src/main/java/com/getcode/solana/Instruction.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/Instruction.kt similarity index 96% rename from api/src/main/java/com/getcode/solana/Instruction.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/Instruction.kt index 8c0fb38bf..13e3539d4 100644 --- a/api/src/main/java/com/getcode/solana/Instruction.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/Instruction.kt @@ -1,10 +1,12 @@ package com.getcode.solana -import com.getcode.network.repository.hexEncodedString +import com.getcode.solana.keys.AccountMeta import com.getcode.solana.keys.PublicKey import com.getcode.solana.keys.base58 +import com.getcode.solana.keys.description import com.getcode.utils.DataSlice.consume import com.getcode.utils.DataSlice.prefix +import com.getcode.utils.hexEncodedString data class Instruction( val program: PublicKey, diff --git a/api/src/main/java/com/getcode/solana/Message.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/Message.kt similarity index 81% rename from api/src/main/java/com/getcode/solana/Message.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/Message.kt index 70ac22f14..e14ce8a08 100644 --- a/api/src/main/java/com/getcode/solana/Message.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/Message.kt @@ -1,17 +1,14 @@ package com.getcode.solana -import com.getcode.solana.keys.Hash -import com.getcode.solana.keys.LENGTH_32 -import com.getcode.solana.keys.PublicKey -import com.getcode.solana.keys.base58 +import com.getcode.solana.keys.filterUniqueAccounts import com.getcode.utils.DataSlice.chunk import com.getcode.utils.DataSlice.consume import com.getcode.utils.DataSlice.tail data class Message( val header: MessageHeader, - val accounts: List, - var recentBlockhash: Hash, + val accounts: List, + var recentBlockhash: com.getcode.solana.keys.Hash, val instructions: List, ) { fun encode(): ByteArray { @@ -39,14 +36,18 @@ data class Message( // Decode `accountKeys` val (accountCount, accountData) = ShortVec.decodeLen(payload) - val messageAccounts = accountData.chunk(LENGTH_32, accountCount) { PublicKey(it) } + val messageAccounts = accountData.chunk(com.getcode.solana.keys.LENGTH_32, accountCount) { + com.getcode.solana.keys.PublicKey( + it + ) + } ?: return null - payload = accountData.tail(LENGTH_32 * accountCount) + payload = accountData.tail(com.getcode.solana.keys.LENGTH_32 * accountCount) // Decode `recentBlockHash` - val hashConsumed = payload.consume(LENGTH_32) - val hash = Hash(hashConsumed.consumed) + val hashConsumed = payload.consume(com.getcode.solana.keys.LENGTH_32) + val hash = com.getcode.solana.keys.Hash(hashConsumed.consumed) payload = hashConsumed.remaining @@ -66,7 +67,7 @@ data class Message( } val metaAccounts = messageAccounts.mapIndexed { index, account -> - AccountMeta( + com.getcode.solana.keys.AccountMeta( publicKey = account, isSigner = index < header.requiredSignatures, isWritable = index < header.requiredSignatures - header.readOnlySigners || @@ -86,8 +87,8 @@ data class Message( } fun newInstance( - accounts: List, - recentBlockhash: Hash /* = com.getcode.solana.keys.Key32 */, + accounts: List, + recentBlockhash: com.getcode.solana.keys.Hash /* = com.getcode.solana.keys.Key32 */, instructions: List ): Message { // Sort the account meta's based on: diff --git a/api/src/main/java/com/getcode/solana/MessageHeader.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/MessageHeader.kt similarity index 100% rename from api/src/main/java/com/getcode/solana/MessageHeader.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/MessageHeader.kt diff --git a/api/src/main/java/com/getcode/solana/ShortVec.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/ShortVec.kt similarity index 100% rename from api/src/main/java/com/getcode/solana/ShortVec.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/ShortVec.kt diff --git a/api/src/main/java/com/getcode/solana/SolanaTransaction.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/SolanaTransaction.kt similarity index 81% rename from api/src/main/java/com/getcode/solana/SolanaTransaction.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/SolanaTransaction.kt index 664566bfa..e39146cbf 100644 --- a/api/src/main/java/com/getcode/solana/SolanaTransaction.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/SolanaTransaction.kt @@ -1,17 +1,13 @@ package com.getcode.solana import com.getcode.ed25519.Ed25519 -import com.getcode.solana.instructions.InstructionType import com.getcode.solana.keys.Hash -import com.getcode.solana.keys.LENGTH_64 -import com.getcode.solana.keys.PublicKey -import com.getcode.solana.keys.Signature import com.getcode.solana.keys.base58 +import com.getcode.solana.keys.description import com.getcode.utils.DataSlice.chunk import com.getcode.utils.DataSlice.tail import com.getcode.utils.printDiff import com.getcode.utils.printMatch -import timber.log.Timber /* @@ -39,7 +35,7 @@ import timber.log.Timber - Structs: Fields are serialized in order as declared. No metadata about structs are serialized. */ -data class SolanaTransaction(val message: Message, val signatures: List) { +data class SolanaTransaction(val message: Message, val signatures: List) { val identifier get() = signatures.first() @@ -49,14 +45,14 @@ data class SolanaTransaction(val message: Message, val signatures: List { + fun sign(vararg keyPairs: Ed25519.KeyPair): List { val requiredSignatureCount = message.header.requiredSignatures if (keyPairs.size > requiredSignatureCount) { throw Exception(SigningError.tooManySigners.name) } val messageData = message.encode() - val newSignatures = mutableListOf() + val newSignatures = mutableListOf() keyPairs.forEach { keyPair -> val signatureIndex = @@ -66,7 +62,7 @@ data class SolanaTransaction(val message: Message, val signatures: List findInstruction(cb: (instruction: Instruction) -> InstructionType): T? { + inline fun findInstruction(cb: (instruction: Instruction) -> com.getcode.solana.instructions.InstructionType): T? { message.instructions.forEach { try { val res = cb(it) @@ -111,28 +107,32 @@ data class SolanaTransaction(val message: Message, val signatures: List): SolanaTransaction? { val (signatureCount, payload) = ShortVec.decodeLen(list) - if (payload.size < signatureCount * LENGTH_64) { + if (payload.size < signatureCount * com.getcode.solana.keys.LENGTH_64) { return null } - val signatures: List = - payload.chunk(size = LENGTH_64, count = signatureCount) { Signature(it) }.orEmpty() - val messageData = payload.tail(signatureCount * LENGTH_64) + val signatures: List = + payload.chunk(size = com.getcode.solana.keys.LENGTH_64, count = signatureCount) { + com.getcode.solana.keys.Signature( + it + ) + }.orEmpty() + val messageData = payload.tail(signatureCount * com.getcode.solana.keys.LENGTH_64) val message = Message.newInstance(messageData) ?: return null return SolanaTransaction(signatures = signatures.toMutableList(), message = message) } fun newInstance( - payer: PublicKey, + payer: com.getcode.solana.keys.PublicKey, recentBlockhash: Hash?, instructions: List ): SolanaTransaction { - val accounts = mutableListOf() - accounts.add(AccountMeta.payer(publicKey = payer)) + val accounts = mutableListOf() + accounts.add(com.getcode.solana.keys.AccountMeta.payer(publicKey = payer)) instructions.forEach { - accounts.add(AccountMeta.program(publicKey = it.program)) + accounts.add(com.getcode.solana.keys.AccountMeta.program(publicKey = it.program)) accounts.addAll(it.accounts) } @@ -142,10 +142,10 @@ data class SolanaTransaction(val message: Message, val signatures: List() + val signatures = mutableListOf() .apply { for (i in 0 until message.header.requiredSignatures) { - add(Signature.zero) + add(com.getcode.solana.keys.Signature.zero) } } diff --git a/api/src/main/java/com/getcode/solana/instructions/InstructionType.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/InstructionType.kt similarity index 96% rename from api/src/main/java/com/getcode/solana/instructions/InstructionType.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/InstructionType.kt index 0da7ea7b0..4cf47ac5e 100644 --- a/api/src/main/java/com/getcode/solana/instructions/InstructionType.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/InstructionType.kt @@ -1,7 +1,6 @@ package com.getcode.solana.instructions import com.getcode.solana.Instruction -import com.getcode.solana.instructions.programs.SwapValidatorProgram import com.getcode.solana.keys.PublicKey import com.getcode.utils.DataSlice import com.getcode.utils.DataSlice.consume diff --git a/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/AssociatedTokenProgram.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/AssociatedTokenProgram.kt new file mode 100644 index 000000000..4e2c2926c --- /dev/null +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/AssociatedTokenProgram.kt @@ -0,0 +1,11 @@ +package com.getcode.solana.instructions.programs + +import com.getcode.vendor.Base58 + +class AssociatedTokenProgram { + companion object { + val address = com.getcode.solana.keys.PublicKey( + Base58.decode("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL").toList() + ) + } +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/AssociatedTokenProgram_CreateAccount.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/AssociatedTokenProgram_CreateAccount.kt similarity index 83% rename from api/src/main/java/com/getcode/solana/instructions/programs/AssociatedTokenProgram_CreateAccount.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/AssociatedTokenProgram_CreateAccount.kt index ca059d169..4e99c072d 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/AssociatedTokenProgram_CreateAccount.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/AssociatedTokenProgram_CreateAccount.kt @@ -1,11 +1,9 @@ package com.getcode.solana.instructions.programs -import com.getcode.solana.AccountMeta +import com.getcode.solana.keys.AccountMeta import com.getcode.solana.Instruction -import com.getcode.solana.instructions.InstructionType -import com.getcode.solana.keys.PublicKey -data class TimelockAccounts(val state: PublicKey, val vault: PublicKey) +data class TimelockAccounts(val state: com.getcode.solana.keys.PublicKey, val vault: com.getcode.solana.keys.PublicKey) /// Create an associated token account for the given wallet address and token mint /// Accounts expected by this instruction: @@ -23,11 +21,11 @@ data class TimelockAccounts(val state: PublicKey, val vault: PublicKey) /// class AssociatedTokenProgram_CreateAccount( - val subsidizer: PublicKey, - val owner: PublicKey, - val associatedTokenAccount: PublicKey, - val mint: PublicKey -): InstructionType { + val subsidizer: com.getcode.solana.keys.PublicKey, + val owner: com.getcode.solana.keys.PublicKey, + val associatedTokenAccount: com.getcode.solana.keys.PublicKey, + val mint: com.getcode.solana.keys.PublicKey +): com.getcode.solana.instructions.InstructionType { override fun instruction(): Instruction { return Instruction( program = AssociatedTokenProgram.address, diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/ComputeBudgetProgram.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/ComputeBudgetProgram.kt similarity index 67% rename from api/src/main/java/com/getcode/solana/instructions/programs/ComputeBudgetProgram.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/ComputeBudgetProgram.kt index bf5974433..6bb0209ae 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/ComputeBudgetProgram.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/ComputeBudgetProgram.kt @@ -1,7 +1,5 @@ package com.getcode.solana.instructions.programs -import com.getcode.solana.instructions.CommandType -import com.getcode.solana.keys.PublicKey import com.getcode.vendor.Base58 class ComputeBudgetProgram { @@ -13,8 +11,10 @@ class ComputeBudgetProgram { setComputeUnitPrice(3), } - companion object: CommandType() { - override val address = PublicKey(Base58.decode("ComputeBudget111111111111111111111111111111").toList()) + companion object: com.getcode.solana.instructions.CommandType() { + override val address = com.getcode.solana.keys.PublicKey( + Base58.decode("ComputeBudget111111111111111111111111111111").toList() + ) override val commandByteLength: Int get() = Byte.SIZE_BYTES diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/ComputeBudgetProgram_RequestUnits.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/ComputeBudgetProgram_RequestUnits.kt similarity index 95% rename from api/src/main/java/com/getcode/solana/instructions/programs/ComputeBudgetProgram_RequestUnits.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/ComputeBudgetProgram_RequestUnits.kt index 2f50e2d53..def8fafda 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/ComputeBudgetProgram_RequestUnits.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/ComputeBudgetProgram_RequestUnits.kt @@ -1,7 +1,6 @@ package com.getcode.solana.instructions.programs import com.getcode.solana.Instruction -import com.getcode.solana.instructions.InstructionType import com.getcode.solana.instructions.programs.ComputeBudgetProgram.Command import com.getcode.utils.DataSlice.consume import org.kin.sdk.base.tools.byteArrayToLong @@ -11,7 +10,7 @@ import org.kin.sdk.base.tools.longToByteArray class ComputeBudgetProgram_RequestUnits( val limit: Long, val bump: Byte, -): InstructionType { +): com.getcode.solana.instructions.InstructionType { override fun instruction(): Instruction { return Instruction( program = ComputeBudgetProgram.address, diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/ComputeBudgetProgram_SetComputeUnitLimit.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/ComputeBudgetProgram_SetComputeUnitLimit.kt similarity index 89% rename from api/src/main/java/com/getcode/solana/instructions/programs/ComputeBudgetProgram_SetComputeUnitLimit.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/ComputeBudgetProgram_SetComputeUnitLimit.kt index f0e0d0409..b94ad1ddb 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/ComputeBudgetProgram_SetComputeUnitLimit.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/ComputeBudgetProgram_SetComputeUnitLimit.kt @@ -1,17 +1,14 @@ package com.getcode.solana.instructions.programs import com.getcode.solana.Instruction -import com.getcode.solana.instructions.InstructionType import com.getcode.utils.DataSlice.consume -import com.getcode.utils.DataSlice.toLong import org.kin.sdk.base.tools.byteArrayToInt import org.kin.sdk.base.tools.intToByteArray -import org.kin.sdk.base.tools.longToByteArray class ComputeBudgetProgram_SetComputeUnitLimit( val limit: Int, val bump: Byte, -): InstructionType { +): com.getcode.solana.instructions.InstructionType { override fun instruction(): Instruction { return Instruction( diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/ComputeBudgetProgram_SetComputeUnitPrice.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/ComputeBudgetProgram_SetComputeUnitPrice.kt similarity index 91% rename from api/src/main/java/com/getcode/solana/instructions/programs/ComputeBudgetProgram_SetComputeUnitPrice.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/ComputeBudgetProgram_SetComputeUnitPrice.kt index d22be9908..076978171 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/ComputeBudgetProgram_SetComputeUnitPrice.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/ComputeBudgetProgram_SetComputeUnitPrice.kt @@ -1,16 +1,14 @@ package com.getcode.solana.instructions.programs import com.getcode.solana.Instruction -import com.getcode.solana.instructions.InstructionType import com.getcode.utils.DataSlice.consume import org.kin.sdk.base.tools.byteArrayToLong -import org.kin.sdk.base.tools.intToByteArray import org.kin.sdk.base.tools.longToByteArray class ComputeBudgetProgram_SetComputeUnitPrice( val microLamports: Long, val bump: Byte, -): InstructionType { +): com.getcode.solana.instructions.InstructionType { override fun instruction(): Instruction { return Instruction( program = ComputeBudgetProgram.address, diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/MemoProgram.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/MemoProgram.kt similarity index 85% rename from api/src/main/java/com/getcode/solana/instructions/programs/MemoProgram.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/MemoProgram.kt index 3c2332c98..777024a27 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/MemoProgram.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/MemoProgram.kt @@ -1,6 +1,5 @@ package com.getcode.solana.instructions.programs -import com.getcode.solana.keys.PublicKey import com.getcode.vendor.Base58 /// Send tokens from one accounts to another. Accounts expected by this instruction: @@ -24,6 +23,8 @@ import com.getcode.vendor.Base58 open class MemoProgram(val data: List) { companion object { - val address = PublicKey(Base58.decode("Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo").toList()) + val address = com.getcode.solana.keys.PublicKey( + Base58.decode("Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo").toList() + ) } } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/MemoProgram_Memo.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/MemoProgram_Memo.kt similarity index 78% rename from api/src/main/java/com/getcode/solana/instructions/programs/MemoProgram_Memo.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/MemoProgram_Memo.kt index 96509ed72..d1e3c0405 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/MemoProgram_Memo.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/MemoProgram_Memo.kt @@ -1,6 +1,5 @@ package com.getcode.solana.instructions.programs -import com.getcode.model.SocialUser import com.getcode.solana.AgoraMemo import com.getcode.solana.Instruction import com.getcode.solana.TransferType @@ -23,12 +22,6 @@ class MemoProgram_Memo(data: List) : MemoProgram(data) { ) } - fun newInstance(tipMetadata: SocialUser): MemoProgram_Memo { - val memo = "tip:${tipMetadata.platform}:${tipMetadata.username}" - - return MemoProgram_Memo(memo.toByteArray().toList()) - } - /*fun newInstance(instruction: Instruction): MemoProgram_Memo { MemoProgram.parse(instruction = instruction, expectingAccounts = 0) return MemoProgram_Memo( diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/SwapValidatorProgram.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/SwapValidatorProgram.kt similarity index 68% rename from api/src/main/java/com/getcode/solana/instructions/programs/SwapValidatorProgram.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/SwapValidatorProgram.kt index 17306d288..7cf43e877 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/SwapValidatorProgram.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/SwapValidatorProgram.kt @@ -1,7 +1,5 @@ package com.getcode.solana.instructions.programs -import com.getcode.solana.instructions.CommandType -import com.getcode.solana.keys.PublicKey import com.getcode.utils.DataSlice.toLong import com.getcode.vendor.Base58 @@ -11,8 +9,10 @@ class SwapValidatorProgram { postSwap("A1758AB339B7D59F".toULong(16).toLong()) } - companion object: CommandType() { - override val address = PublicKey(Base58.decode("sWvA66HNNvgamibZe88v3NN5nQwE8tp3KitfViFjukA").toList()) + companion object: com.getcode.solana.instructions.CommandType() { + override val address = com.getcode.solana.keys.PublicKey( + Base58.decode("sWvA66HNNvgamibZe88v3NN5nQwE8tp3KitfViFjukA").toList() + ) override val commandByteLength: Int get() = Long.SIZE_BYTES override fun commandLookup(bytes: ByteArray): Command? { diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/SwapValidatorProgram_PostSwap.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/SwapValidatorProgram_PostSwap.kt similarity index 87% rename from api/src/main/java/com/getcode/solana/instructions/programs/SwapValidatorProgram_PostSwap.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/SwapValidatorProgram_PostSwap.kt index 7f8be508d..1c68e3002 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/SwapValidatorProgram_PostSwap.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/SwapValidatorProgram_PostSwap.kt @@ -1,22 +1,20 @@ package com.getcode.solana.instructions.programs -import com.getcode.network.repository.toByteArray -import com.getcode.solana.AccountMeta +import com.getcode.solana.keys.AccountMeta import com.getcode.solana.Instruction -import com.getcode.solana.instructions.InstructionType -import com.getcode.solana.keys.PublicKey import com.getcode.utils.DataSlice.consume import com.getcode.utils.DataSlice.toLong +import com.getcode.utils.toByteArray class SwapValidatorProgram_PostSwap( val stateBump: Byte, val maxToSend: Long, val minToReceive: Long, - val preSwapState: PublicKey, - val source: PublicKey, - val destination: PublicKey, - val payer: PublicKey, -) : InstructionType { + val preSwapState: com.getcode.solana.keys.PublicKey, + val source: com.getcode.solana.keys.PublicKey, + val destination: com.getcode.solana.keys.PublicKey, + val payer: com.getcode.solana.keys.PublicKey, +) : com.getcode.solana.instructions.InstructionType { override fun instruction(): Instruction { return Instruction( diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/SwapValidatorProgram_PreSwap.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/SwapValidatorProgram_PreSwap.kt similarity index 81% rename from api/src/main/java/com/getcode/solana/instructions/programs/SwapValidatorProgram_PreSwap.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/SwapValidatorProgram_PreSwap.kt index 0d0f44850..52ffa8293 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/SwapValidatorProgram_PreSwap.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/SwapValidatorProgram_PreSwap.kt @@ -1,22 +1,18 @@ package com.getcode.solana.instructions.programs -import com.getcode.network.repository.toByteArray -import com.getcode.solana.AccountMeta +import com.getcode.solana.keys.AccountMeta import com.getcode.solana.Instruction -import com.getcode.solana.description -import com.getcode.solana.instructions.InstructionType -import com.getcode.solana.keys.PublicKey -import timber.log.Timber +import com.getcode.utils.toByteArray class SwapValidatorProgram_PreSwap( - val preSwapState: PublicKey, - val user: PublicKey, - val source: PublicKey, - val destination: PublicKey, - val nonce: PublicKey, - val payer: PublicKey, + val preSwapState: com.getcode.solana.keys.PublicKey, + val user: com.getcode.solana.keys.PublicKey, + val source: com.getcode.solana.keys.PublicKey, + val destination: com.getcode.solana.keys.PublicKey, + val nonce: com.getcode.solana.keys.PublicKey, + val payer: com.getcode.solana.keys.PublicKey, val remainingAccounts: List, -): InstructionType { +): com.getcode.solana.instructions.InstructionType { override fun instruction(): Instruction { val accounts = mutableListOf( AccountMeta.writable(publicKey = preSwapState), diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/SysVar.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/SysVar.kt similarity index 85% rename from api/src/main/java/com/getcode/solana/instructions/programs/SysVar.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/SysVar.kt index 21669c74e..4008036cc 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/SysVar.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/SysVar.kt @@ -1,7 +1,7 @@ package com.getcode.solana.instructions.programs -import com.getcode.network.repository.decodeBase58 import com.getcode.solana.keys.PublicKey +import com.getcode.utils.decodeBase58 enum class SysVar(val value: String) { clock ("SysvarC1ock11111111111111111111111111111111"), @@ -14,5 +14,6 @@ enum class SysVar(val value: String) { slotHistory ("SysvarS1otHistory11111111111111111111111111"), stackHistory ("SysvarStakeHistory1111111111111111111111111"); - fun address(): PublicKey = PublicKey(this.value.decodeBase58().toList()) + fun address(): PublicKey = + PublicKey(this.value.decodeBase58().toList()) } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/SystemProgram.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/SystemProgram.kt similarity index 86% rename from api/src/main/java/com/getcode/solana/instructions/programs/SystemProgram.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/SystemProgram.kt index 9139a9190..42c79844a 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/SystemProgram.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/SystemProgram.kt @@ -21,6 +21,7 @@ class SystemProgram { } companion object { - val address = PublicKey(ByteArray(LENGTH_32) { 0 }.toList()) + val address = + PublicKey(ByteArray(LENGTH_32) { 0 }.toList()) } } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/SystemProgram_AdvanceNonce.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/SystemProgram_AdvanceNonce.kt similarity index 83% rename from api/src/main/java/com/getcode/solana/instructions/programs/SystemProgram_AdvanceNonce.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/SystemProgram_AdvanceNonce.kt index 1b5b63d0c..a44049e1c 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/SystemProgram_AdvanceNonce.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/SystemProgram_AdvanceNonce.kt @@ -1,15 +1,13 @@ package com.getcode.solana.instructions.programs -import com.getcode.solana.AccountMeta +import com.getcode.solana.keys.AccountMeta import com.getcode.solana.Instruction -import com.getcode.solana.instructions.InstructionType -import com.getcode.solana.keys.PublicKey import org.kin.sdk.base.tools.intToByteArray class SystemProgram_AdvanceNonce( - val nonce: PublicKey, - val authority: PublicKey, -) : InstructionType { + val nonce: com.getcode.solana.keys.PublicKey, + val authority: com.getcode.solana.keys.PublicKey, +) : com.getcode.solana.instructions.InstructionType { override fun instruction(): Instruction { return Instruction( program = SystemProgram.address, diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram.kt similarity index 72% rename from api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram.kt index 165832d69..0728e7cb3 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram.kt @@ -1,7 +1,5 @@ package com.getcode.solana.instructions.programs -import com.getcode.solana.instructions.CommandType -import com.getcode.solana.keys.PublicKey import com.getcode.utils.DataSlice.toLong import com.getcode.vendor.Base58 @@ -17,9 +15,13 @@ class TimelockProgram { closeAccounts ("01CAFA22E95EDEAB".toULong(16).toLong()), burnDustWithAuthority ("2D4E7C0EDAFF2A27".toULong(16).toLong()), } - companion object: CommandType() { - override val address = PublicKey(Base58.decode("time2Z2SCnn3qYg3ULKVtdkh8YmZ5jFdKicnA1W2YnJ").toList()) - val legacyAddress = PublicKey(Base58.decode("timeDBoQGL52du9K7EtrhkJSqpiFapE9dHrmDVkuZx6").toList()) + companion object: com.getcode.solana.instructions.CommandType() { + override val address = com.getcode.solana.keys.PublicKey( + Base58.decode("time2Z2SCnn3qYg3ULKVtdkh8YmZ5jFdKicnA1W2YnJ").toList() + ) + val legacyAddress = com.getcode.solana.keys.PublicKey( + Base58.decode("timeDBoQGL52du9K7EtrhkJSqpiFapE9dHrmDVkuZx6").toList() + ) override val commandByteLength: Int get() = Long.SIZE_BYTES diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_BurnDustWithAuthority.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_BurnDustWithAuthority.kt similarity index 85% rename from api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_BurnDustWithAuthority.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_BurnDustWithAuthority.kt index b5dc5a3fb..ec02db974 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_BurnDustWithAuthority.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_BurnDustWithAuthority.kt @@ -1,27 +1,25 @@ package com.getcode.solana.instructions.programs import com.getcode.model.Kin -import com.getcode.network.repository.toByteArray -import com.getcode.solana.AccountMeta +import com.getcode.solana.keys.AccountMeta import com.getcode.solana.Instruction -import com.getcode.solana.instructions.InstructionType import com.getcode.solana.instructions.programs.TimelockProgram.Command -import com.getcode.solana.keys.PublicKey import com.getcode.utils.DataSlice.consume +import com.getcode.utils.toByteArray import org.kin.sdk.base.tools.byteArrayToLong import org.kin.sdk.base.tools.longToByteArray class TimelockProgram_BurnDustWithAuthority( - val timelock: PublicKey, - val vault: PublicKey, - val vaultOwner: PublicKey, - val timeAuthority: PublicKey, - val mint: PublicKey, - val payer: PublicKey, + val timelock: com.getcode.solana.keys.PublicKey, + val vault: com.getcode.solana.keys.PublicKey, + val vaultOwner: com.getcode.solana.keys.PublicKey, + val timeAuthority: com.getcode.solana.keys.PublicKey, + val mint: com.getcode.solana.keys.PublicKey, + val payer: com.getcode.solana.keys.PublicKey, val bump: Byte, val maxAmount: Kin, val legacy: Boolean = false, -) : InstructionType { +) : com.getcode.solana.instructions.InstructionType { override fun instruction(): Instruction { return Instruction( program = if (legacy) TimelockProgram.legacyAddress else TimelockProgram.address, diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_CloseAccounts.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_CloseAccounts.kt similarity index 85% rename from api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_CloseAccounts.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_CloseAccounts.kt index 13e5ff6be..66e9d2c2d 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_CloseAccounts.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_CloseAccounts.kt @@ -1,22 +1,20 @@ package com.getcode.solana.instructions.programs -import com.getcode.network.repository.toByteArray -import com.getcode.solana.AccountMeta +import com.getcode.solana.keys.AccountMeta import com.getcode.solana.Instruction -import com.getcode.solana.instructions.InstructionType import com.getcode.solana.instructions.programs.TimelockProgram.Command.closeAccounts -import com.getcode.solana.keys.PublicKey import com.getcode.utils.DataSlice.consume +import com.getcode.utils.toByteArray // Reference: https://github.com/code-wallet/code-server/blob/privacy-v3/pkg/solana/timelock/instruction_closeaccounts.go class TimelockProgram_CloseAccounts( - val timelock: PublicKey, - val vault: PublicKey, - val closeAuthority: PublicKey, - val payer: PublicKey, + val timelock: com.getcode.solana.keys.PublicKey, + val vault: com.getcode.solana.keys.PublicKey, + val closeAuthority: com.getcode.solana.keys.PublicKey, + val payer: com.getcode.solana.keys.PublicKey, val bump: Byte, val legacy: Boolean = false, -) : InstructionType { +) : com.getcode.solana.instructions.InstructionType { override fun instruction(): Instruction { return Instruction( program = if (legacy) TimelockProgram.legacyAddress else TimelockProgram.address, diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_DeactivateLock.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_DeactivateLock.kt similarity index 85% rename from api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_DeactivateLock.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_DeactivateLock.kt index 2266898ce..d29c50869 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_DeactivateLock.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_DeactivateLock.kt @@ -1,21 +1,19 @@ package com.getcode.solana.instructions.programs -import com.getcode.network.repository.toByteArray -import com.getcode.solana.AccountMeta +import com.getcode.solana.keys.AccountMeta import com.getcode.solana.Instruction -import com.getcode.solana.instructions.InstructionType import com.getcode.solana.instructions.programs.TimelockProgram.Command -import com.getcode.solana.keys.PublicKey import com.getcode.utils.DataSlice.consume +import com.getcode.utils.toByteArray // Reference: https://github.com/code-wallet/code-server/blob/privacy-v3/pkg/solana/timelock/instruction_deactivate.go class TimelockProgram_DeactivateLock( - val timelock: PublicKey, - val vaultOwner: PublicKey, - val payer: PublicKey, + val timelock: com.getcode.solana.keys.PublicKey, + val vaultOwner: com.getcode.solana.keys.PublicKey, + val payer: com.getcode.solana.keys.PublicKey, val bump: Byte, val legacy: Boolean = false, -) : InstructionType { +) : com.getcode.solana.instructions.InstructionType { override fun instruction(): Instruction { return Instruction( program = if (legacy) TimelockProgram.legacyAddress else TimelockProgram.address, diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_Initialize.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_Initialize.kt similarity index 82% rename from api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_Initialize.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_Initialize.kt index 5efc39519..fcd7f5ee0 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_Initialize.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_Initialize.kt @@ -1,26 +1,23 @@ package com.getcode.solana.instructions.programs -import com.getcode.network.repository.toByteArray -import com.getcode.solana.AccountMeta +import com.getcode.solana.keys.AccountMeta import com.getcode.solana.Instruction -import com.getcode.solana.instructions.InstructionType import com.getcode.solana.instructions.programs.TimelockProgram.Command -import com.getcode.solana.keys.PublicKey -import com.getcode.utils.DataSlice.consume +import com.getcode.utils.toByteArray import org.kin.sdk.base.tools.byteArrayToLong import org.kin.sdk.base.tools.longToByteArray // Reference: https://github.com/code-wallet/code-server/blob/master/pkg/solana/timelock/instruction_initialize.go class TimelockProgram_Initialize( - val nonce: PublicKey, - val timelock: PublicKey, - val vault: PublicKey, - val vaultOwner: PublicKey, - val mint: PublicKey, - val timeAuthority: PublicKey, - val payer: PublicKey, + val nonce: com.getcode.solana.keys.PublicKey, + val timelock: com.getcode.solana.keys.PublicKey, + val vault: com.getcode.solana.keys.PublicKey, + val vaultOwner: com.getcode.solana.keys.PublicKey, + val mint: com.getcode.solana.keys.PublicKey, + val timeAuthority: com.getcode.solana.keys.PublicKey, + val payer: com.getcode.solana.keys.PublicKey, val lockout: Long -) : InstructionType { +) : com.getcode.solana.instructions.InstructionType { override fun instruction(): Instruction { return Instruction( program = TimelockProgram.address, diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_RevokeLockWithAuthority.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_RevokeLockWithAuthority.kt similarity index 85% rename from api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_RevokeLockWithAuthority.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_RevokeLockWithAuthority.kt index 01ffca4cc..80a473465 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_RevokeLockWithAuthority.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_RevokeLockWithAuthority.kt @@ -1,22 +1,20 @@ package com.getcode.solana.instructions.programs -import com.getcode.network.repository.toByteArray -import com.getcode.solana.AccountMeta +import com.getcode.solana.keys.AccountMeta import com.getcode.solana.Instruction -import com.getcode.solana.instructions.InstructionType import com.getcode.solana.instructions.programs.TimelockProgram.Command -import com.getcode.solana.keys.PublicKey import com.getcode.utils.DataSlice.consume +import com.getcode.utils.toByteArray // Reference: https://github.com/code-wallet/code-server/blob/privacy-v3/pkg/solana/timelock/instruction_revokelockwithauthority.go class TimelockProgram_RevokeLockWithAuthority( - val timelock: PublicKey, - val vault: PublicKey, - val closeAuthority: PublicKey, - val payer: PublicKey, + val timelock: com.getcode.solana.keys.PublicKey, + val vault: com.getcode.solana.keys.PublicKey, + val closeAuthority: com.getcode.solana.keys.PublicKey, + val payer: com.getcode.solana.keys.PublicKey, val bump: Byte, val legacy: Boolean = false, -) : InstructionType { +) : com.getcode.solana.instructions.InstructionType { override fun instruction(): Instruction { return Instruction( program = if (legacy) TimelockProgram.legacyAddress else TimelockProgram.address, diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_TransferWithAuthority.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_TransferWithAuthority.kt similarity index 85% rename from api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_TransferWithAuthority.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_TransferWithAuthority.kt index e794af482..815d2ba35 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_TransferWithAuthority.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_TransferWithAuthority.kt @@ -1,27 +1,25 @@ package com.getcode.solana.instructions.programs import com.getcode.model.Kin -import com.getcode.network.repository.toByteArray -import com.getcode.solana.AccountMeta +import com.getcode.solana.keys.AccountMeta import com.getcode.solana.Instruction -import com.getcode.solana.instructions.InstructionType import com.getcode.solana.instructions.programs.TimelockProgram.Command -import com.getcode.solana.keys.PublicKey import com.getcode.utils.DataSlice.consume +import com.getcode.utils.toByteArray import org.kin.sdk.base.tools.byteArrayToLong import org.kin.sdk.base.tools.longToByteArray // Reference: https://github.com/code-wallet/code-server/blob/master/pkg/solana/timelock/instruction_transferwithauthority.go class TimelockProgram_TransferWithAuthority( - val timelock: PublicKey, - val vault: PublicKey, - val vaultOwner: PublicKey, - val timeAuthority: PublicKey, - val destination: PublicKey, - val payer: PublicKey, + val timelock: com.getcode.solana.keys.PublicKey, + val vault: com.getcode.solana.keys.PublicKey, + val vaultOwner: com.getcode.solana.keys.PublicKey, + val timeAuthority: com.getcode.solana.keys.PublicKey, + val destination: com.getcode.solana.keys.PublicKey, + val payer: com.getcode.solana.keys.PublicKey, val bump: Byte, val kin: Kin -) : InstructionType { +) : com.getcode.solana.instructions.InstructionType { override fun instruction(): Instruction { return Instruction( program = TimelockProgram.address, diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_Withdraw.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_Withdraw.kt similarity index 84% rename from api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_Withdraw.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_Withdraw.kt index ee11fcea7..f559cb4f2 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/TimelockProgram_Withdraw.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TimelockProgram_Withdraw.kt @@ -1,24 +1,21 @@ package com.getcode.solana.instructions.programs -import com.getcode.network.repository.toByteArray -import com.getcode.solana.AccountMeta +import com.getcode.solana.keys.AccountMeta import com.getcode.solana.Instruction -import com.getcode.solana.instructions.InstructionType import com.getcode.solana.instructions.programs.TimelockProgram.Command -import com.getcode.solana.keys.PublicKey import com.getcode.utils.DataSlice.consume -import org.kin.sdk.base.tools.longToByteArray +import com.getcode.utils.toByteArray // Reference: https://github.com/code-wallet/code-server/blob/privacy-v3/pkg/solana/timelock/instruction_withdraw.go class TimelockProgram_Withdraw( - val timelock: PublicKey, - val vault: PublicKey, - val vaultOwner: PublicKey, - val destination: PublicKey, - val payer: PublicKey, + val timelock: com.getcode.solana.keys.PublicKey, + val vault: com.getcode.solana.keys.PublicKey, + val vaultOwner: com.getcode.solana.keys.PublicKey, + val destination: com.getcode.solana.keys.PublicKey, + val payer: com.getcode.solana.keys.PublicKey, val bump: Byte, val legacy: Boolean = false, -) : InstructionType { +) : com.getcode.solana.instructions.InstructionType { override fun instruction(): Instruction { return Instruction( program = if (legacy) TimelockProgram.legacyAddress else TimelockProgram.address, diff --git a/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TokenProgram.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TokenProgram.kt new file mode 100644 index 000000000..e87030fc2 --- /dev/null +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/instructions/programs/TokenProgram.kt @@ -0,0 +1,11 @@ +package com.getcode.solana.instructions.programs + +import com.getcode.vendor.Base58 + +class TokenProgram { + companion object { + val address = com.getcode.solana.keys.PublicKey( + Base58.decode("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA").toList() + ) + } +} \ No newline at end of file diff --git a/libs/crypto/solana/src/main/kotlin/com/getcode/solana/keys/ProgramDerivedAccount.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/keys/ProgramDerivedAccount.kt new file mode 100644 index 000000000..2df275ee9 --- /dev/null +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/keys/ProgramDerivedAccount.kt @@ -0,0 +1,81 @@ +package com.getcode.solana.keys + +import com.getcode.crypt.Sha256Hash +import com.getcode.model.Kin +import org.kin.sdk.base.models.toUTF8Bytes + +data class ProgramDerivedAccount(val publicKey: PublicKey, val bump: Int) + +class TimelockDerivedAccounts( + val owner: PublicKey, + val state: ProgramDerivedAccount, + val vault: ProgramDerivedAccount +) { + companion object { + const val lockoutInDays: Long = 21 + const val dataVersion: Long = 3 + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as TimelockDerivedAccounts + + if (owner != other.owner) return false + if (state != other.state) return false + if (vault != other.vault) return false + + return true + } + + override fun hashCode(): Int { + var result = owner.hashCode() + result = 31 * result + state.hashCode() + result = 31 * result + vault.hashCode() + return result + } +} + +class SplitterCommitmentAccounts( + val treasury: PublicKey, + val destination: PublicKey, + val recentRoot: Hash, + val transcript: Hash, + + val state: ProgramDerivedAccount, + val vault: ProgramDerivedAccount, +) { + companion object +} + +data class SplitterTranscript( + val intentId: PublicKey, + val actionId: Int, val + amount: Kin, + val source: PublicKey, + val destination: PublicKey +) { + val description = + "receipt[${intentId.base58()}, $actionId]: " + + "transfer ${amount.quarks} quarks " + + "from ${source.base58()} to ${destination.base58()}" + + val transcriptHash: Hash = + Hash(Sha256Hash.hash(description.toUTF8Bytes()).toList()) +} + +data class AssociatedTokenAccount( + val owner: PublicKey, + val ata: ProgramDerivedAccount, +) { + companion object +} + +data class PreSwapStateAccount( + val owner: PublicKey, + val state: ProgramDerivedAccount, +) { + companion object +} + diff --git a/api/src/main/java/com/getcode/utils/DataSlice.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/utils/DataSlice.kt similarity index 100% rename from api/src/main/java/com/getcode/utils/DataSlice.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/utils/DataSlice.kt diff --git a/api/src/main/java/com/getcode/utils/Differ.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/utils/Differ.kt similarity index 100% rename from api/src/main/java/com/getcode/utils/Differ.kt rename to libs/crypto/solana/src/main/kotlin/com/getcode/utils/Differ.kt diff --git a/libs/currency/.gitignore b/libs/currency/.gitignore new file mode 100644 index 000000000..9f2a07880 --- /dev/null +++ b/libs/currency/.gitignore @@ -0,0 +1,2 @@ +build/ +.gradle/ diff --git a/libs/currency/build.gradle.kts b/libs/currency/build.gradle.kts new file mode 100644 index 000000000..2a5a0b924 --- /dev/null +++ b/libs/currency/build.gradle.kts @@ -0,0 +1,66 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.libs.currency" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.compose_compiler + } +} + +dependencies { + implementation(project(":ui:resources")) + + //Jetpack compose + implementation(platform(Libs.compose_bom)) + implementation(Libs.compose_ui) + + implementation(Libs.kotlinx_coroutines_core) + implementation(Libs.kotlinx_serialization_json) + implementation(Libs.kotlinx_datetime) + implementation(Libs.inject) + + + androidTestImplementation(Libs.androidx_junit) + androidTestImplementation(Libs.junit) + androidTestImplementation(Libs.androidx_test_runner) + implementation(Libs.hilt) + + implementation(Libs.timber) + implementation(Libs.bugsnag) +} diff --git a/app/src/main/java/com/getcode/util/KinAmountExt.kt b/libs/currency/src/main/kotlin/com/getcode/extensions/KinAmountExt.kt similarity index 67% rename from app/src/main/java/com/getcode/util/KinAmountExt.kt rename to libs/currency/src/main/kotlin/com/getcode/extensions/KinAmountExt.kt index 742f2eb09..bc5ce4e08 100644 --- a/app/src/main/java/com/getcode/util/KinAmountExt.kt +++ b/libs/currency/src/main/kotlin/com/getcode/extensions/KinAmountExt.kt @@ -1,44 +1,49 @@ -package com.getcode.util +package com.getcode.extensions import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import com.getcode.LocalCurrencyUtils -import com.getcode.R +import com.getcode.libs.currency.R import com.getcode.model.Currency import com.getcode.model.KinAmount +import com.getcode.util.resources.AndroidResources import com.getcode.util.resources.ResourceHelper import com.getcode.utils.FormatUtils +import com.getcode.utils.Kin +import com.getcode.utils.LocalCurrencyUtils +import com.getcode.utils.formatAmountString fun KinAmount.formattedRaw() = FormatUtils.formatWholeRoundDown(kin.toKin().toDouble()) @Composable fun KinAmount.formatted( currency: Currency, + amount: Double = fiat, suffix: String = stringResource(R.string.core_ofKin) ) = formatAmountString( resources = AndroidResources(context = LocalContext.current), currency = currency, - amount = fiat, + amount = amount, suffix = suffix ) @Composable -fun KinAmount.formatted(suffix: String = stringResource(R.string.core_ofKin)): String { +fun KinAmount.formatted(amount: Double = fiat, suffix: String = stringResource(R.string.core_ofKin)): String { val currency = LocalCurrencyUtils.current?.getCurrency(rate.currency.name) ?: Currency.Kin - return formatted(currency = currency, suffix = suffix) + return formatted(currency = currency, amount = amount, suffix = suffix) } fun KinAmount.formatted( resources: ResourceHelper, currency: Currency, + amount: Double = fiat, kinSuffix: String = "", suffix: String = resources.getString(R.string.core_ofKin) ) = formatAmountString( resources = resources, currency = currency, - amount = fiat, + amount = amount, kinSuffix = kinSuffix, suffix = suffix ) \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/Currency.kt b/libs/currency/src/main/kotlin/com/getcode/model/Currency.kt similarity index 100% rename from api/src/main/java/com/getcode/model/Currency.kt rename to libs/currency/src/main/kotlin/com/getcode/model/Currency.kt diff --git a/api/src/main/java/com/getcode/model/CurrencyCode.kt b/libs/currency/src/main/kotlin/com/getcode/model/CurrencyCode.kt similarity index 100% rename from api/src/main/java/com/getcode/model/CurrencyCode.kt rename to libs/currency/src/main/kotlin/com/getcode/model/CurrencyCode.kt diff --git a/api/src/main/java/com/getcode/model/Kin.kt b/libs/currency/src/main/kotlin/com/getcode/model/Kin.kt similarity index 94% rename from api/src/main/java/com/getcode/model/Kin.kt rename to libs/currency/src/main/kotlin/com/getcode/model/Kin.kt index cfd3f0a3f..069558889 100644 --- a/api/src/main/java/com/getcode/model/Kin.kt +++ b/libs/currency/src/main/kotlin/com/getcode/model/Kin.kt @@ -3,7 +3,8 @@ package com.getcode.model import java.math.BigDecimal import java.math.RoundingMode import kotlin.math.floor -import kotlin.math.min + +interface Value data class Kin(val quarks: Long): Value { init { @@ -63,6 +64,9 @@ data class Kin(val quarks: Long): Value { } } +inline val Int.kin + get() = Kin.fromKin(this) + private fun min(a: Kin, b: Kin): Kin { if (a.quarks > b.quarks) { return b @@ -72,4 +76,6 @@ private fun min(a: Kin, b: Kin): Kin { } val Kin.description: String - get() = "K ${toKinTruncating().quarks} ${fractionalQuarks()}" \ No newline at end of file + get() = "K ${toKinTruncating().quarks} ${fractionalQuarks()}" + +fun List.sum(): Kin = this.fold(0.kin) { acc, kin -> acc + kin } diff --git a/libs/currency/src/main/kotlin/com/getcode/model/KinAmount.kt b/libs/currency/src/main/kotlin/com/getcode/model/KinAmount.kt new file mode 100644 index 000000000..3e075203d --- /dev/null +++ b/libs/currency/src/main/kotlin/com/getcode/model/KinAmount.kt @@ -0,0 +1,57 @@ +package com.getcode.model + +import com.getcode.model.Kin.Companion.fromKin +import com.getcode.utils.serializer.KinQuarksSerializer +import com.getcode.utils.serializer.RateAsStringSerializer +import kotlinx.serialization.Serializable + +@Serializable +data class KinAmount( + @Serializable(with = KinQuarksSerializer::class) + val kin: Kin, + val fiat: Double, + @Serializable(with = RateAsStringSerializer::class) + val rate: Rate +) { + fun truncating() = KinAmount( + kin = kin.toKinTruncating(), + fiat = fiat, + rate = rate + ) + + fun replacing(rate: Rate): KinAmount { + return newInstance(this.kin, rate) + } + + companion object { + val Zero = newInstance(0, Rate.oneToOne) + + fun newInstance(kin: Long, rate: Rate): KinAmount { + return newInstance(fromKin(kin), rate) + } + + fun fromQuarks(quarks: Long): KinAmount = newInstance(quarks, Rate.oneToOne) + + fun newInstance(kin: Kin, rate: Rate): KinAmount { + return KinAmount( + kin = kin, + fiat = kin.toFiat(fx = rate.fx), + rate = rate + ) + } + } +} + +fun List.sum(): KinAmount { + if (this.isEmpty()) return KinAmount.Zero + + val totalKin = this.fold(Kin(0)) { acc, kinAmount -> acc + kinAmount.kin } + val totalFiat = this.sumOf { it.fiat } + val rate = this.first().rate // Assuming the rate is consistent or using the first item's rate + + return KinAmount( + kin = totalKin, + fiat = totalFiat, + rate = rate + ) +} \ No newline at end of file diff --git a/libs/currency/src/main/kotlin/com/getcode/model/Rate.kt b/libs/currency/src/main/kotlin/com/getcode/model/Rate.kt new file mode 100644 index 000000000..27f321479 --- /dev/null +++ b/libs/currency/src/main/kotlin/com/getcode/model/Rate.kt @@ -0,0 +1,15 @@ +package com.getcode.model + +import kotlinx.serialization.Serializable + +@Serializable +data class Rate( + val fx: Double, + val currency: CurrencyCode +) { + companion object { + val oneToOne = Rate(fx = 1.0, currency = CurrencyCode.KIN) + } +} + +fun Rate?.orOneToOne() = this ?: Rate.oneToOne \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/RegionCode.kt b/libs/currency/src/main/kotlin/com/getcode/model/RegionCode.kt similarity index 100% rename from api/src/main/java/com/getcode/model/RegionCode.kt rename to libs/currency/src/main/kotlin/com/getcode/model/RegionCode.kt diff --git a/app/src/main/java/com/getcode/util/Currency.kt b/libs/currency/src/main/kotlin/com/getcode/utils/Currency.kt similarity index 66% rename from app/src/main/java/com/getcode/util/Currency.kt rename to libs/currency/src/main/kotlin/com/getcode/utils/Currency.kt index ecef5fb40..4d5afba40 100644 --- a/app/src/main/java/com/getcode/util/Currency.kt +++ b/libs/currency/src/main/kotlin/com/getcode/utils/Currency.kt @@ -1,36 +1,19 @@ -package com.getcode.util +package com.getcode.utils import android.annotation.SuppressLint -import android.content.Context import androidx.annotation.DrawableRes import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import com.getcode.BuildConfig -import com.getcode.R +import com.getcode.libs.currency.R import com.getcode.model.Currency import com.getcode.model.CurrencyCode import com.getcode.model.KinAmount +import com.getcode.util.resources.LocalResources import com.getcode.util.resources.ResourceHelper import com.getcode.util.resources.ResourceType -import com.getcode.utils.FormatUtils val Currency.Companion.Kin: Currency get() = Currency(CurrencyCode.KIN.name, "Kin", R.drawable.ic_currency_kin, "K", 1.00) -@DrawableRes -fun Currency.flagResId(context: Context): Int? { - if (code == "KIN") return R.drawable.ic_currency_kin - if (code == "XAF") return R.drawable.ic_currency_xaf - if (code == "XOF") return R.drawable.ic_currency_xof - - return CurrencyCode.tryValueOf(code)?.let { currency -> - currency.getRegion()?.name - }?.let { regionName -> - getFlag(context, regionName) - } -} - - @get:DrawableRes val CurrencyCode.flagResId: Int? @Composable get() { @@ -39,23 +22,10 @@ val CurrencyCode.flagResId: Int? if (this.name == "XOF") return R.drawable.ic_currency_xof return getRegion()?.name?.let { - return getFlag(LocalContext.current, it) + getFlag(LocalResources.current!!, it) } } - -@DrawableRes -@SuppressLint("DiscouragedApi") -fun getFlag(context: Context, countryCode: String): Int? { - if (countryCode.isEmpty()) return null - val resourceName = "ic_flag_${countryCode.lowercase()}" - return context.resources.getIdentifier( - resourceName, - "drawable", - BuildConfig.APPLICATION_ID - ).let { if (it == 0) null else it } -} - @DrawableRes fun Currency.flagResId(resourceHelper: ResourceHelper): Int? { if (code == "KIN") return R.drawable.ic_currency_kin @@ -101,13 +71,13 @@ fun formatAmountString( resources: ResourceHelper, currency: Currency, amount: Double, - kinSuffix: String = "", - suffix: String = resources.getString(R.string.core_ofKin) + kinSuffix: String = resources.getKinSuffix(), + suffix: String = resources.getOfKinSuffix() ): String { val isKin = currency.code == Currency.Kin.code return if (isKin) { - "${FormatUtils.formatWholeRoundDown(amount)} ${resources.getString(R.string.core_kin)} $kinSuffix" + "${FormatUtils.formatWholeRoundDown(amount)}${if (kinSuffix.isNotEmpty()) " $kinSuffix" else ""}" } else { when { currency.code == currency.symbol -> { diff --git a/app/src/main/java/com/getcode/util/CurrencyUtils.kt b/libs/currency/src/main/kotlin/com/getcode/utils/CurrencyUtils.kt similarity index 68% rename from app/src/main/java/com/getcode/util/CurrencyUtils.kt rename to libs/currency/src/main/kotlin/com/getcode/utils/CurrencyUtils.kt index 1be4d8371..f7d454686 100644 --- a/app/src/main/java/com/getcode/util/CurrencyUtils.kt +++ b/libs/currency/src/main/kotlin/com/getcode/utils/CurrencyUtils.kt @@ -1,28 +1,31 @@ -package com.getcode.util +package com.getcode.utils import android.annotation.SuppressLint -import android.content.Context -import com.getcode.BuildConfig -import com.getcode.R +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import com.getcode.libs.currency.R import com.getcode.model.Currency import com.getcode.model.CurrencyCode import com.getcode.model.Rate import com.getcode.model.RegionCode -import dagger.hilt.android.qualifiers.ApplicationContext +import com.getcode.util.resources.ResourceHelper +import com.getcode.util.resources.ResourceType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import java.util.* +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton +val LocalCurrencyUtils: ProvidableCompositionLocal = staticCompositionLocalOf { null } -// TODO: see if Exchange can absorb this? @Singleton class CurrencyUtils @Inject constructor( - @ApplicationContext private val context: Context + private val resources: ResourceHelper, ) { private val currencies: List by lazy { runBlocking { @@ -65,10 +68,9 @@ class CurrencyUtils @Inject constructor( fun getFlag(countryCode: String): Int? { if (countryCode.isEmpty()) return null val resourceName = "ic_flag_${countryCode.lowercase()}" - return context.resources.getIdentifier( + return resources.getIdentifier( resourceName, - "drawable", - BuildConfig.APPLICATION_ID + ResourceType.Drawable ).let { if (it == 0) null else it } } @@ -91,39 +93,32 @@ class CurrencyUtils @Inject constructor( ) } - /** - * TODO - * Instead of splitting this list roughly in half it would be faster - * to chunk the list and build a concurrent hash map of currencies and sorting that - */ private suspend fun initCurrencies(): List { val scope = CoroutineScope(Dispatchers.Default) - val chunk1 = scope.async { - CurrencyCode.entries.toTypedArray().copyOfRange(0, 75).map { currencyCode -> - try { - getCurrency(currencyCode, scope) - } catch (_: Exception) { - null - } - } - .toMutableList() - .filterNotNull() - } - - val chunk2 = scope.async { - CurrencyCode.entries.toTypedArray() - .copyOfRange(75, CurrencyCode.entries.size).map { currencyCode -> - try { - getCurrency(currencyCode, scope) - } catch (_: Exception) { - null + val currencyMap = ConcurrentHashMap() + + val chunkSize = 25 + val chunks = CurrencyCode.entries.chunked(chunkSize) + + // Process each chunk asynchronously + val jobs = chunks.map { chunk -> + scope.async { + chunk.forEach { currencyCode -> + try { + val currency = getCurrency(currencyCode, scope) + currencyMap[currency.name] = currency + } catch (_: Exception) { + // Handle exceptions if needed + } } } - .toMutableList() - .filterNotNull() } - return chunk1.await().plus(chunk2.await()).sortedBy { it.name } + // Wait for all jobs to complete + jobs.awaitAll() + + // Sort the currencies by name + return currencyMap.values.sortedBy { it.name } } } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/utils/FormatUtils.kt b/libs/currency/src/main/kotlin/com/getcode/utils/FormatUtils.kt similarity index 87% rename from api/src/main/java/com/getcode/utils/FormatUtils.kt rename to libs/currency/src/main/kotlin/com/getcode/utils/FormatUtils.kt index 9b1d8fa0c..29e0490ed 100644 --- a/api/src/main/java/com/getcode/utils/FormatUtils.kt +++ b/libs/currency/src/main/kotlin/com/getcode/utils/FormatUtils.kt @@ -1,6 +1,5 @@ package com.getcode.utils -import com.getcode.model.CurrencyCode import com.getcode.model.Kin import java.text.NumberFormat import java.util.* @@ -22,7 +21,7 @@ object FormatUtils { fun formatCurrency(value: Double, locale: Locale): String = NumberFormat.getCurrencyInstance(locale).format(value) - fun formatCurrency(value: Double, currencyCode: CurrencyCode): String { + fun formatCurrency(value: Double, currencyCode: com.getcode.model.CurrencyCode): String { val locale = NumberFormat.getAvailableLocales().firstOrNull { NumberFormat.getCurrencyInstance(it).currency?.currencyCode == currencyCode.name } ?: Locale.getDefault() @@ -38,3 +37,7 @@ object FormatUtils { return formatCurrency(value, locale) } } + +fun Int.withCommas(): String { + return this.toString().reversed().chunked(3).joinToString(",").reversed() +} diff --git a/api/src/main/java/com/getcode/utils/serializer/KinSerializer.kt b/libs/currency/src/main/kotlin/com/getcode/utils/serializer/KinSerializer.kt similarity index 100% rename from api/src/main/java/com/getcode/utils/serializer/KinSerializer.kt rename to libs/currency/src/main/kotlin/com/getcode/utils/serializer/KinSerializer.kt diff --git a/api/src/main/java/com/getcode/utils/serializer/RateSerializer.kt b/libs/currency/src/main/kotlin/com/getcode/utils/serializer/RateSerializer.kt similarity index 100% rename from api/src/main/java/com/getcode/utils/serializer/RateSerializer.kt rename to libs/currency/src/main/kotlin/com/getcode/utils/serializer/RateSerializer.kt diff --git a/app/src/main/res/drawable/ic_currency_dollar_active.xml b/libs/currency/src/main/res/drawable/ic_currency_dollar_active.xml similarity index 100% rename from app/src/main/res/drawable/ic_currency_dollar_active.xml rename to libs/currency/src/main/res/drawable/ic_currency_dollar_active.xml diff --git a/app/src/main/res/drawable/ic_currency_dollar_inactive.xml b/libs/currency/src/main/res/drawable/ic_currency_dollar_inactive.xml similarity index 100% rename from app/src/main/res/drawable/ic_currency_dollar_inactive.xml rename to libs/currency/src/main/res/drawable/ic_currency_dollar_inactive.xml diff --git a/app/src/main/res/drawable/ic_currency_kin.xml b/libs/currency/src/main/res/drawable/ic_currency_kin.xml similarity index 100% rename from app/src/main/res/drawable/ic_currency_kin.xml rename to libs/currency/src/main/res/drawable/ic_currency_kin.xml diff --git a/app/src/main/res/drawable/ic_currency_xaf.xml b/libs/currency/src/main/res/drawable/ic_currency_xaf.xml similarity index 100% rename from app/src/main/res/drawable/ic_currency_xaf.xml rename to libs/currency/src/main/res/drawable/ic_currency_xaf.xml diff --git a/app/src/main/res/drawable/ic_currency_xof.xml b/libs/currency/src/main/res/drawable/ic_currency_xof.xml similarity index 100% rename from app/src/main/res/drawable/ic_currency_xof.xml rename to libs/currency/src/main/res/drawable/ic_currency_xof.xml diff --git a/common/components/.gitignore b/libs/datetime/.gitignore similarity index 100% rename from common/components/.gitignore rename to libs/datetime/.gitignore diff --git a/libs/datetime/build.gradle.kts b/libs/datetime/build.gradle.kts new file mode 100644 index 000000000..e15ee1254 --- /dev/null +++ b/libs/datetime/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.util.datetime" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.compose_compiler + } +} + +dependencies { + api(Libs.kotlinx_datetime) + + //Jetpack compose + implementation(platform(Libs.compose_bom)) + implementation(Libs.compose_ui) + + + implementation(Libs.timber) +} diff --git a/app/src/main/java/com/getcode/util/DateUtils.kt b/libs/datetime/src/main/kotlin/com/getcode/util/DateUtils.kt similarity index 96% rename from app/src/main/java/com/getcode/util/DateUtils.kt rename to libs/datetime/src/main/kotlin/com/getcode/util/DateUtils.kt index bcce855dd..4cc1b8bea 100644 --- a/app/src/main/java/com/getcode/util/DateUtils.kt +++ b/libs/datetime/src/main/kotlin/com/getcode/util/DateUtils.kt @@ -1,12 +1,9 @@ package com.getcode.util -import android.content.Context import android.text.format.DateFormat import android.text.format.DateUtils import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext -import com.getcode.utils.atStartOfDay -import com.getcode.utils.toLocalDate import kotlinx.datetime.Clock import kotlinx.datetime.Instant import java.util.Calendar diff --git a/api/src/main/java/com/getcode/utils/Instant.kt b/libs/datetime/src/main/kotlin/com/getcode/util/Instant.kt similarity index 97% rename from api/src/main/java/com/getcode/utils/Instant.kt rename to libs/datetime/src/main/kotlin/com/getcode/util/Instant.kt index bb4a80e9a..6c720fcaf 100644 --- a/api/src/main/java/com/getcode/utils/Instant.kt +++ b/libs/datetime/src/main/kotlin/com/getcode/util/Instant.kt @@ -1,4 +1,4 @@ -package com.getcode.utils +package com.getcode.util import kotlinx.datetime.DatePeriod import kotlinx.datetime.DateTimeUnit diff --git a/common/resources/.gitignore b/libs/encryption/base58/.gitignore similarity index 100% rename from common/resources/.gitignore rename to libs/encryption/base58/.gitignore diff --git a/libs/encryption/base58/build.gradle.kts b/libs/encryption/base58/build.gradle.kts new file mode 100644 index 000000000..01600f6e4 --- /dev/null +++ b/libs/encryption/base58/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.encryption.base58" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } +} + +dependencies { + implementation(Libs.kin_sdk) +} diff --git a/api/src/main/java/com/getcode/vendor/Base58.kt b/libs/encryption/base58/src/main/kotlin/com/getcode/vendor/Base58.kt similarity index 100% rename from api/src/main/java/com/getcode/vendor/Base58.kt rename to libs/encryption/base58/src/main/kotlin/com/getcode/vendor/Base58.kt diff --git a/crypto/ed25519/.gitignore b/libs/encryption/ed25519/.gitignore similarity index 100% rename from crypto/ed25519/.gitignore rename to libs/encryption/ed25519/.gitignore diff --git a/crypto/ed25519/CMakeLists.txt b/libs/encryption/ed25519/CMakeLists.txt similarity index 100% rename from crypto/ed25519/CMakeLists.txt rename to libs/encryption/ed25519/CMakeLists.txt diff --git a/crypto/ed25519/build.gradle.kts b/libs/encryption/ed25519/build.gradle.kts similarity index 92% rename from crypto/ed25519/build.gradle.kts rename to libs/encryption/ed25519/build.gradle.kts index 551c7a7b4..84a3f523f 100644 --- a/crypto/ed25519/build.gradle.kts +++ b/libs/encryption/ed25519/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - namespace = "${Android.namespace}.ed25519" + namespace = "${Android.codeNamespace}.ed25519" compileSdk = Android.compileSdkVersion defaultConfig { minSdk = Android.minSdkVersion diff --git a/crypto/ed25519/libs/base64/CMakeLists.txt b/libs/encryption/ed25519/libs/base64/CMakeLists.txt similarity index 100% rename from crypto/ed25519/libs/base64/CMakeLists.txt rename to libs/encryption/ed25519/libs/base64/CMakeLists.txt diff --git a/crypto/ed25519/libs/base64/base64.cpp b/libs/encryption/ed25519/libs/base64/base64.cpp similarity index 100% rename from crypto/ed25519/libs/base64/base64.cpp rename to libs/encryption/ed25519/libs/base64/base64.cpp diff --git a/crypto/ed25519/libs/base64/base64.hpp b/libs/encryption/ed25519/libs/base64/base64.hpp similarity index 100% rename from crypto/ed25519/libs/base64/base64.hpp rename to libs/encryption/ed25519/libs/base64/base64.hpp diff --git a/crypto/ed25519/libs/codeScanner/CMakeLists.txt b/libs/encryption/ed25519/libs/codeScanner/CMakeLists.txt similarity index 100% rename from crypto/ed25519/libs/codeScanner/CMakeLists.txt rename to libs/encryption/ed25519/libs/codeScanner/CMakeLists.txt diff --git a/crypto/ed25519/libs/codeScanner/src/Array.h b/libs/encryption/ed25519/libs/codeScanner/src/Array.h similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/Array.h rename to libs/encryption/ed25519/libs/codeScanner/src/Array.h diff --git a/crypto/ed25519/libs/codeScanner/src/Counted.h b/libs/encryption/ed25519/libs/codeScanner/src/Counted.h similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/Counted.h rename to libs/encryption/ed25519/libs/codeScanner/src/Counted.h diff --git a/crypto/ed25519/libs/codeScanner/src/Exception.cpp b/libs/encryption/ed25519/libs/codeScanner/src/Exception.cpp similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/Exception.cpp rename to libs/encryption/ed25519/libs/codeScanner/src/Exception.cpp diff --git a/crypto/ed25519/libs/codeScanner/src/Exception.h b/libs/encryption/ed25519/libs/codeScanner/src/Exception.h similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/Exception.h rename to libs/encryption/ed25519/libs/codeScanner/src/Exception.h diff --git a/crypto/ed25519/libs/codeScanner/src/GenericGF.cpp b/libs/encryption/ed25519/libs/codeScanner/src/GenericGF.cpp similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/GenericGF.cpp rename to libs/encryption/ed25519/libs/codeScanner/src/GenericGF.cpp diff --git a/crypto/ed25519/libs/codeScanner/src/GenericGF.h b/libs/encryption/ed25519/libs/codeScanner/src/GenericGF.h similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/GenericGF.h rename to libs/encryption/ed25519/libs/codeScanner/src/GenericGF.h diff --git a/crypto/ed25519/libs/codeScanner/src/GenericGFPoly.cpp b/libs/encryption/ed25519/libs/codeScanner/src/GenericGFPoly.cpp similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/GenericGFPoly.cpp rename to libs/encryption/ed25519/libs/codeScanner/src/GenericGFPoly.cpp diff --git a/crypto/ed25519/libs/codeScanner/src/GenericGFPoly.h b/libs/encryption/ed25519/libs/codeScanner/src/GenericGFPoly.h similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/GenericGFPoly.h rename to libs/encryption/ed25519/libs/codeScanner/src/GenericGFPoly.h diff --git a/crypto/ed25519/libs/codeScanner/src/IllegalArgumentException.cpp b/libs/encryption/ed25519/libs/codeScanner/src/IllegalArgumentException.cpp similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/IllegalArgumentException.cpp rename to libs/encryption/ed25519/libs/codeScanner/src/IllegalArgumentException.cpp diff --git a/crypto/ed25519/libs/codeScanner/src/IllegalArgumentException.h b/libs/encryption/ed25519/libs/codeScanner/src/IllegalArgumentException.h similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/IllegalArgumentException.h rename to libs/encryption/ed25519/libs/codeScanner/src/IllegalArgumentException.h diff --git a/crypto/ed25519/libs/codeScanner/src/IllegalStateException.h b/libs/encryption/ed25519/libs/codeScanner/src/IllegalStateException.h similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/IllegalStateException.h rename to libs/encryption/ed25519/libs/codeScanner/src/IllegalStateException.h diff --git a/crypto/ed25519/libs/codeScanner/src/ReaderException.h b/libs/encryption/ed25519/libs/codeScanner/src/ReaderException.h similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/ReaderException.h rename to libs/encryption/ed25519/libs/codeScanner/src/ReaderException.h diff --git a/crypto/ed25519/libs/codeScanner/src/ReedSolomonDecoder.cpp b/libs/encryption/ed25519/libs/codeScanner/src/ReedSolomonDecoder.cpp similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/ReedSolomonDecoder.cpp rename to libs/encryption/ed25519/libs/codeScanner/src/ReedSolomonDecoder.cpp diff --git a/crypto/ed25519/libs/codeScanner/src/ReedSolomonDecoder.h b/libs/encryption/ed25519/libs/codeScanner/src/ReedSolomonDecoder.h similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/ReedSolomonDecoder.h rename to libs/encryption/ed25519/libs/codeScanner/src/ReedSolomonDecoder.h diff --git a/crypto/ed25519/libs/codeScanner/src/ReedSolomonEncoder.cpp b/libs/encryption/ed25519/libs/codeScanner/src/ReedSolomonEncoder.cpp similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/ReedSolomonEncoder.cpp rename to libs/encryption/ed25519/libs/codeScanner/src/ReedSolomonEncoder.cpp diff --git a/crypto/ed25519/libs/codeScanner/src/ReedSolomonEncoder.h b/libs/encryption/ed25519/libs/codeScanner/src/ReedSolomonEncoder.h similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/ReedSolomonEncoder.h rename to libs/encryption/ed25519/libs/codeScanner/src/ReedSolomonEncoder.h diff --git a/crypto/ed25519/libs/codeScanner/src/ReedSolomonException.cpp b/libs/encryption/ed25519/libs/codeScanner/src/ReedSolomonException.cpp similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/ReedSolomonException.cpp rename to libs/encryption/ed25519/libs/codeScanner/src/ReedSolomonException.cpp diff --git a/crypto/ed25519/libs/codeScanner/src/ReedSolomonException.h b/libs/encryption/ed25519/libs/codeScanner/src/ReedSolomonException.h similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/ReedSolomonException.h rename to libs/encryption/ed25519/libs/codeScanner/src/ReedSolomonException.h diff --git a/crypto/ed25519/libs/codeScanner/src/ZXing.h b/libs/encryption/ed25519/libs/codeScanner/src/ZXing.h similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/ZXing.h rename to libs/encryption/ed25519/libs/codeScanner/src/ZXing.h diff --git a/crypto/ed25519/libs/codeScanner/src/kikcode_constants.h b/libs/encryption/ed25519/libs/codeScanner/src/kikcode_constants.h similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/kikcode_constants.h rename to libs/encryption/ed25519/libs/codeScanner/src/kikcode_constants.h diff --git a/crypto/ed25519/libs/codeScanner/src/kikcode_encoding.cpp b/libs/encryption/ed25519/libs/codeScanner/src/kikcode_encoding.cpp similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/kikcode_encoding.cpp rename to libs/encryption/ed25519/libs/codeScanner/src/kikcode_encoding.cpp diff --git a/crypto/ed25519/libs/codeScanner/src/kikcode_encoding.h b/libs/encryption/ed25519/libs/codeScanner/src/kikcode_encoding.h similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/kikcode_encoding.h rename to libs/encryption/ed25519/libs/codeScanner/src/kikcode_encoding.h diff --git a/crypto/ed25519/libs/codeScanner/src/kikcode_encoding_jni.cpp b/libs/encryption/ed25519/libs/codeScanner/src/kikcode_encoding_jni.cpp similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/kikcode_encoding_jni.cpp rename to libs/encryption/ed25519/libs/codeScanner/src/kikcode_encoding_jni.cpp diff --git a/crypto/ed25519/libs/codeScanner/src/kikcode_encoding_jni.h b/libs/encryption/ed25519/libs/codeScanner/src/kikcode_encoding_jni.h similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/kikcode_encoding_jni.h rename to libs/encryption/ed25519/libs/codeScanner/src/kikcode_encoding_jni.h diff --git a/crypto/ed25519/libs/codeScanner/src/kikcodes.cpp b/libs/encryption/ed25519/libs/codeScanner/src/kikcodes.cpp similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/kikcodes.cpp rename to libs/encryption/ed25519/libs/codeScanner/src/kikcodes.cpp diff --git a/crypto/ed25519/libs/codeScanner/src/kikcodes.h b/libs/encryption/ed25519/libs/codeScanner/src/kikcodes.h similarity index 100% rename from crypto/ed25519/libs/codeScanner/src/kikcodes.h rename to libs/encryption/ed25519/libs/codeScanner/src/kikcodes.h diff --git a/crypto/ed25519/libs/ed25519/CMakeLists.txt b/libs/encryption/ed25519/libs/ed25519/CMakeLists.txt similarity index 100% rename from crypto/ed25519/libs/ed25519/CMakeLists.txt rename to libs/encryption/ed25519/libs/ed25519/CMakeLists.txt diff --git a/crypto/ed25519/libs/ed25519/license.txt b/libs/encryption/ed25519/libs/ed25519/license.txt similarity index 100% rename from crypto/ed25519/libs/ed25519/license.txt rename to libs/encryption/ed25519/libs/ed25519/license.txt diff --git a/crypto/ed25519/libs/ed25519/readme.md b/libs/encryption/ed25519/libs/ed25519/readme.md similarity index 100% rename from crypto/ed25519/libs/ed25519/readme.md rename to libs/encryption/ed25519/libs/ed25519/readme.md diff --git a/crypto/ed25519/libs/ed25519/src/add_scalar.c b/libs/encryption/ed25519/libs/ed25519/src/add_scalar.c similarity index 100% rename from crypto/ed25519/libs/ed25519/src/add_scalar.c rename to libs/encryption/ed25519/libs/ed25519/src/add_scalar.c diff --git a/crypto/ed25519/libs/ed25519/src/ed25519.h b/libs/encryption/ed25519/libs/ed25519/src/ed25519.h similarity index 100% rename from crypto/ed25519/libs/ed25519/src/ed25519.h rename to libs/encryption/ed25519/libs/ed25519/src/ed25519.h diff --git a/crypto/ed25519/libs/ed25519/src/fe.c b/libs/encryption/ed25519/libs/ed25519/src/fe.c similarity index 100% rename from crypto/ed25519/libs/ed25519/src/fe.c rename to libs/encryption/ed25519/libs/ed25519/src/fe.c diff --git a/crypto/ed25519/libs/ed25519/src/fe.h b/libs/encryption/ed25519/libs/ed25519/src/fe.h similarity index 100% rename from crypto/ed25519/libs/ed25519/src/fe.h rename to libs/encryption/ed25519/libs/ed25519/src/fe.h diff --git a/crypto/ed25519/libs/ed25519/src/fixedint.h b/libs/encryption/ed25519/libs/ed25519/src/fixedint.h similarity index 100% rename from crypto/ed25519/libs/ed25519/src/fixedint.h rename to libs/encryption/ed25519/libs/ed25519/src/fixedint.h diff --git a/crypto/ed25519/libs/ed25519/src/ge.c b/libs/encryption/ed25519/libs/ed25519/src/ge.c similarity index 100% rename from crypto/ed25519/libs/ed25519/src/ge.c rename to libs/encryption/ed25519/libs/ed25519/src/ge.c diff --git a/crypto/ed25519/libs/ed25519/src/ge.h b/libs/encryption/ed25519/libs/ed25519/src/ge.h similarity index 100% rename from crypto/ed25519/libs/ed25519/src/ge.h rename to libs/encryption/ed25519/libs/ed25519/src/ge.h diff --git a/crypto/ed25519/libs/ed25519/src/key_exchange.c b/libs/encryption/ed25519/libs/ed25519/src/key_exchange.c similarity index 100% rename from crypto/ed25519/libs/ed25519/src/key_exchange.c rename to libs/encryption/ed25519/libs/ed25519/src/key_exchange.c diff --git a/crypto/ed25519/libs/ed25519/src/keypair.c b/libs/encryption/ed25519/libs/ed25519/src/keypair.c similarity index 100% rename from crypto/ed25519/libs/ed25519/src/keypair.c rename to libs/encryption/ed25519/libs/ed25519/src/keypair.c diff --git a/crypto/ed25519/libs/ed25519/src/precomp_data.h b/libs/encryption/ed25519/libs/ed25519/src/precomp_data.h similarity index 100% rename from crypto/ed25519/libs/ed25519/src/precomp_data.h rename to libs/encryption/ed25519/libs/ed25519/src/precomp_data.h diff --git a/crypto/ed25519/libs/ed25519/src/sc.c b/libs/encryption/ed25519/libs/ed25519/src/sc.c similarity index 100% rename from crypto/ed25519/libs/ed25519/src/sc.c rename to libs/encryption/ed25519/libs/ed25519/src/sc.c diff --git a/crypto/ed25519/libs/ed25519/src/sc.h b/libs/encryption/ed25519/libs/ed25519/src/sc.h similarity index 100% rename from crypto/ed25519/libs/ed25519/src/sc.h rename to libs/encryption/ed25519/libs/ed25519/src/sc.h diff --git a/crypto/ed25519/libs/ed25519/src/seed.c b/libs/encryption/ed25519/libs/ed25519/src/seed.c similarity index 100% rename from crypto/ed25519/libs/ed25519/src/seed.c rename to libs/encryption/ed25519/libs/ed25519/src/seed.c diff --git a/crypto/ed25519/libs/ed25519/src/sha3.c b/libs/encryption/ed25519/libs/ed25519/src/sha3.c similarity index 100% rename from crypto/ed25519/libs/ed25519/src/sha3.c rename to libs/encryption/ed25519/libs/ed25519/src/sha3.c diff --git a/crypto/ed25519/libs/ed25519/src/sha3.h b/libs/encryption/ed25519/libs/ed25519/src/sha3.h similarity index 100% rename from crypto/ed25519/libs/ed25519/src/sha3.h rename to libs/encryption/ed25519/libs/ed25519/src/sha3.h diff --git a/crypto/ed25519/libs/ed25519/src/sha512.c b/libs/encryption/ed25519/libs/ed25519/src/sha512.c similarity index 100% rename from crypto/ed25519/libs/ed25519/src/sha512.c rename to libs/encryption/ed25519/libs/ed25519/src/sha512.c diff --git a/crypto/ed25519/libs/ed25519/src/sha512.h b/libs/encryption/ed25519/libs/ed25519/src/sha512.h similarity index 100% rename from crypto/ed25519/libs/ed25519/src/sha512.h rename to libs/encryption/ed25519/libs/ed25519/src/sha512.h diff --git a/crypto/ed25519/libs/ed25519/src/sign.c b/libs/encryption/ed25519/libs/ed25519/src/sign.c similarity index 100% rename from crypto/ed25519/libs/ed25519/src/sign.c rename to libs/encryption/ed25519/libs/ed25519/src/sign.c diff --git a/crypto/ed25519/libs/ed25519/src/verify.c b/libs/encryption/ed25519/libs/ed25519/src/verify.c similarity index 100% rename from crypto/ed25519/libs/ed25519/src/verify.c rename to libs/encryption/ed25519/libs/ed25519/src/verify.c diff --git a/crypto/ed25519/libs/ed25519/test.c b/libs/encryption/ed25519/libs/ed25519/test.c similarity index 100% rename from crypto/ed25519/libs/ed25519/test.c rename to libs/encryption/ed25519/libs/ed25519/test.c diff --git a/crypto/ed25519/proguard-rules.pro b/libs/encryption/ed25519/proguard-rules.pro similarity index 100% rename from crypto/ed25519/proguard-rules.pro rename to libs/encryption/ed25519/proguard-rules.pro diff --git a/libs/encryption/ed25519/src/main/AndroidManifest.xml b/libs/encryption/ed25519/src/main/AndroidManifest.xml new file mode 100644 index 000000000..cc947c567 --- /dev/null +++ b/libs/encryption/ed25519/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/crypto/ed25519/src/main/cpp/kik-codes.cpp b/libs/encryption/ed25519/src/main/cpp/kik-codes.cpp similarity index 100% rename from crypto/ed25519/src/main/cpp/kik-codes.cpp rename to libs/encryption/ed25519/src/main/cpp/kik-codes.cpp diff --git a/crypto/ed25519/src/main/cpp/native-lib.cpp b/libs/encryption/ed25519/src/main/cpp/native-lib.cpp similarity index 100% rename from crypto/ed25519/src/main/cpp/native-lib.cpp rename to libs/encryption/ed25519/src/main/cpp/native-lib.cpp diff --git a/crypto/ed25519/src/main/java/com/getcode/codeScanner/CodeScanner.java b/libs/encryption/ed25519/src/main/java/com/getcode/codeScanner/CodeScanner.java similarity index 100% rename from crypto/ed25519/src/main/java/com/getcode/codeScanner/CodeScanner.java rename to libs/encryption/ed25519/src/main/java/com/getcode/codeScanner/CodeScanner.java diff --git a/crypto/ed25519/src/main/java/com/getcode/ed25519/Ed25519.java b/libs/encryption/ed25519/src/main/java/com/getcode/ed25519/Ed25519.java similarity index 100% rename from crypto/ed25519/src/main/java/com/getcode/ed25519/Ed25519.java rename to libs/encryption/ed25519/src/main/java/com/getcode/ed25519/Ed25519.java diff --git a/common/theme/.gitignore b/libs/encryption/hmac/.gitignore similarity index 100% rename from common/theme/.gitignore rename to libs/encryption/hmac/.gitignore diff --git a/libs/encryption/hmac/build.gradle.kts b/libs/encryption/hmac/build.gradle.kts new file mode 100644 index 000000000..73cfea6f4 --- /dev/null +++ b/libs/encryption/hmac/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.encryption.hmac" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } +} + +dependencies { + implementation(Libs.grpc_okhttp) + implementation(Libs.grpc_kotlin) +} diff --git a/api/src/main/java/com/getcode/crypt/Hmac.java b/libs/encryption/hmac/src/main/java/com/getcode/crypt/Hmac.java similarity index 100% rename from api/src/main/java/com/getcode/crypt/Hmac.java rename to libs/encryption/hmac/src/main/java/com/getcode/crypt/Hmac.java diff --git a/libs/encryption/keys/.gitignore b/libs/encryption/keys/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/libs/encryption/keys/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libs/encryption/keys/build.gradle.kts b/libs/encryption/keys/build.gradle.kts new file mode 100644 index 000000000..c3e615adf --- /dev/null +++ b/libs/encryption/keys/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.encryption.keys" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } +} + +dependencies { + implementation(project(":libs:crypto:kin")) + implementation(project(":libs:encryption:base58")) + implementation(project(":libs:encryption:sha256")) + implementation(project(":libs:encryption:utils")) + + implementation(Libs.grpc_okhttp) + implementation(Libs.grpc_kotlin) + implementation(Libs.kotlinx_serialization_json) +} diff --git a/api/src/main/java/com/getcode/solana/AccountMeta.kt b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/AccountMeta.kt similarity index 97% rename from api/src/main/java/com/getcode/solana/AccountMeta.kt rename to libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/AccountMeta.kt index 8451d4cb2..012a00431 100644 --- a/api/src/main/java/com/getcode/solana/AccountMeta.kt +++ b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/AccountMeta.kt @@ -1,7 +1,4 @@ -package com.getcode.solana - -import com.getcode.solana.keys.PublicKey -import com.getcode.solana.keys.base58 +package com.getcode.solana.keys data class AccountMeta( var publicKey: PublicKey, diff --git a/api/src/main/java/com/getcode/solana/keys/Key.kt b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Key.kt similarity index 97% rename from api/src/main/java/com/getcode/solana/keys/Key.kt rename to libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Key.kt index a61919a6d..26fd65233 100644 --- a/api/src/main/java/com/getcode/solana/keys/Key.kt +++ b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Key.kt @@ -1,6 +1,6 @@ package com.getcode.solana.keys -import com.getcode.network.repository.encodeBase64 +import com.getcode.utils.encodeBase64 import org.kin.sdk.base.tools.Base58 abstract class KeyType(bytes: List) { @@ -68,6 +68,7 @@ open class Key32(bytes: List) : KeyType(bytes) { return result } } + open class Key64(bytes: List) : KeyType(bytes) { constructor(base58: String): this (Base58.decode(base58).toList()) diff --git a/api/src/main/java/com/getcode/solana/keys/MerkleProof.kt b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/MerkleProof.kt similarity index 94% rename from api/src/main/java/com/getcode/solana/keys/MerkleProof.kt rename to libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/MerkleProof.kt index 25a0cca88..c58a3b1c8 100644 --- a/api/src/main/java/com/getcode/solana/keys/MerkleProof.kt +++ b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/MerkleProof.kt @@ -1,7 +1,6 @@ package com.getcode.solana.keys import com.getcode.crypt.Sha256Hash -import com.getcode.solana.AccountMeta fun KeyType.verifyContained(merkleRoot: Hash, proof: List): Boolean { return byteArray.verifyContained(merkleRoot, proof) diff --git a/api/src/main/java/com/getcode/solana/keys/Mint.kt b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Mint.kt similarity index 100% rename from api/src/main/java/com/getcode/solana/keys/Mint.kt rename to libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Mint.kt diff --git a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/PublicKey.kt b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/PublicKey.kt new file mode 100644 index 000000000..1fdca46a2 --- /dev/null +++ b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/PublicKey.kt @@ -0,0 +1,43 @@ +package com.getcode.solana.keys + +import com.getcode.utils.serializer.PublicKeyAsStringSerializer +import com.getcode.vendor.Base58 +import com.google.protobuf.ByteString +import kotlinx.serialization.Serializable + +@Serializable(with = PublicKeyAsStringSerializer::class) +class PublicKey(bytes: List) : Key32(bytes) { + + constructor(base58: String): this(Base58.decode(base58).toList()) + + companion object { + + val kin: Mint + get() = Mint(Base58.decode("kinXdEcpDQeHPEuQnqmUgtYykqKGVFq6CeVX5iAHJq6").toList()) + + val usdc: Mint + get() = Mint(org.kin.sdk.base.tools.Base58.decode("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v").toList()) + + fun fromBase58(base58: String): PublicKey { + return PublicKey(Base58.decode(base58).toList()) + } + + fun fromByteString(byteString: ByteString): PublicKey { + return PublicKey(byteString.toByteArray().toList()) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + + other as PublicKey + return size == other.size && bytes == other.bytes + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + size + return result + } + +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/solana/keys/Types.kt b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Types.kt similarity index 100% rename from api/src/main/java/com/getcode/solana/keys/Types.kt rename to libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Types.kt diff --git a/api/src/main/java/com/getcode/utils/serializer/PublicKeySerializer.kt b/libs/encryption/keys/src/main/kotlin/com/getcode/utils/serializer/PublicKeySerializer.kt similarity index 64% rename from api/src/main/java/com/getcode/utils/serializer/PublicKeySerializer.kt rename to libs/encryption/keys/src/main/kotlin/com/getcode/utils/serializer/PublicKeySerializer.kt index 0a7fd06b8..f437c28d7 100644 --- a/api/src/main/java/com/getcode/utils/serializer/PublicKeySerializer.kt +++ b/libs/encryption/keys/src/main/kotlin/com/getcode/utils/serializer/PublicKeySerializer.kt @@ -1,6 +1,5 @@ package com.getcode.utils.serializer -import com.getcode.solana.keys.PublicKey import com.getcode.solana.keys.base58 import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind @@ -9,15 +8,15 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder -object PublicKeyAsStringSerializer : KSerializer { +object PublicKeyAsStringSerializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("PublicKey", PrimitiveKind.STRING) - override fun serialize(encoder: Encoder, value: PublicKey) { + override fun serialize(encoder: Encoder, value: com.getcode.solana.keys.PublicKey) { encoder.encodeString(value.base58()) } - override fun deserialize(decoder: Decoder): PublicKey { + override fun deserialize(decoder: Decoder): com.getcode.solana.keys.PublicKey { val base58 = decoder.decodeString() - return PublicKey.fromBase58(base58) + return com.getcode.solana.keys.PublicKey.fromBase58(base58) } } \ No newline at end of file diff --git a/libs/encryption/mnemonic/.gitignore b/libs/encryption/mnemonic/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/libs/encryption/mnemonic/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libs/encryption/mnemonic/build.gradle.kts b/libs/encryption/mnemonic/build.gradle.kts new file mode 100644 index 000000000..6568aa5b4 --- /dev/null +++ b/libs/encryption/mnemonic/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.encryption.mnemonic" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } +} + +dependencies { + implementation(project(":libs:crypto:kin")) + implementation(project(":libs:encryption:ed25519")) + implementation(project(":libs:encryption:hmac")) + implementation(project(":libs:encryption:sha256")) + implementation(project(":libs:encryption:sha512")) + implementation(project(":libs:encryption:utils")) + implementation(Libs.grpc_okhttp) + implementation(Libs.grpc_kotlin) + implementation(Libs.timber) + implementation(Libs.androidx_core) +} diff --git a/api/src/main/java/com/getcode/crypt/MnemonicCode.java b/libs/encryption/mnemonic/src/main/java/com/getcode/crypt/MnemonicCode.java similarity index 99% rename from api/src/main/java/com/getcode/crypt/MnemonicCode.java rename to libs/encryption/mnemonic/src/main/java/com/getcode/crypt/MnemonicCode.java index 1c8ee3eb4..85ead5510 100644 --- a/api/src/main/java/com/getcode/crypt/MnemonicCode.java +++ b/libs/encryption/mnemonic/src/main/java/com/getcode/crypt/MnemonicCode.java @@ -18,10 +18,10 @@ */ import android.content.res.Resources; -import android.util.Log; +import com.getcode.encryption.mnemonic.R; +import com.getcode.utils.Utils; import com.google.common.base.Stopwatch; -import com.getcode.api.R; import java.io.BufferedReader; import java.io.FileNotFoundException; diff --git a/api/src/main/java/com/getcode/crypt/MnemonicException.java b/libs/encryption/mnemonic/src/main/java/com/getcode/crypt/MnemonicException.java similarity index 100% rename from api/src/main/java/com/getcode/crypt/MnemonicException.java rename to libs/encryption/mnemonic/src/main/java/com/getcode/crypt/MnemonicException.java diff --git a/api/src/main/java/com/getcode/crypt/Derive.kt b/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/Derive.kt similarity index 97% rename from api/src/main/java/com/getcode/crypt/Derive.kt rename to libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/Derive.kt index ca327e418..c368f5850 100644 --- a/api/src/main/java/com/getcode/crypt/Derive.kt +++ b/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/Derive.kt @@ -1,7 +1,7 @@ package com.getcode.crypt import com.getcode.ed25519.Ed25519 -import com.getcode.network.repository.encodeBase64 +import com.getcode.utils.encodeBase64 import org.kin.sdk.base.tools.subByteArray import java.nio.ByteBuffer diff --git a/api/src/main/java/com/getcode/crypt/DerivePath.kt b/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/DerivePath.kt similarity index 100% rename from api/src/main/java/com/getcode/crypt/DerivePath.kt rename to libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/DerivePath.kt diff --git a/api/src/main/java/com/getcode/crypt/DerivedKey.kt b/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/DerivedKey.kt similarity index 96% rename from api/src/main/java/com/getcode/crypt/DerivedKey.kt rename to libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/DerivedKey.kt index f41baffe4..412d3b34e 100644 --- a/api/src/main/java/com/getcode/crypt/DerivedKey.kt +++ b/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/DerivedKey.kt @@ -1,6 +1,5 @@ package com.getcode.crypt -import android.content.Context import com.getcode.ed25519.Ed25519 data class DerivedKey(val path: DerivePath, val keyPair: Ed25519.KeyPair) { diff --git a/api/src/main/java/com/getcode/crypt/MnemonicCache.kt b/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicCache.kt similarity index 100% rename from api/src/main/java/com/getcode/crypt/MnemonicCache.kt rename to libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicCache.kt diff --git a/api/src/main/java/com/getcode/crypt/MnemonicPhrase.kt b/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicPhrase.kt similarity index 88% rename from api/src/main/java/com/getcode/crypt/MnemonicPhrase.kt rename to libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicPhrase.kt index 6853f1868..ee0881f26 100644 --- a/api/src/main/java/com/getcode/crypt/MnemonicPhrase.kt +++ b/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicPhrase.kt @@ -1,10 +1,8 @@ package com.getcode.crypt import com.getcode.ed25519.Ed25519 -import com.getcode.network.repository.decodeBase64 -import com.getcode.network.repository.encodeBase64 -import com.getcode.utils.TraceType -import com.getcode.utils.timedTrace +import com.getcode.utils.decodeBase64 +import com.getcode.utils.encodeBase64 import org.kin.sdk.base.tools.Base58 @@ -18,7 +16,10 @@ class MnemonicPhrase(val kind: Kind, val words: List) { fun getSolanaKeyPair(path: DerivePath = DerivePath.primary): Ed25519.KeyPair { val mnemonicCode = MnemonicCache.cachedCode val mnemonicSeed = MnemonicCache.cache[words to (path.password ?: "nopass")] - ?: MnemonicCode.toSeed(words, path.password.orEmpty()).also { + ?: MnemonicCode.toSeed( + words, + path.password.orEmpty() + ).also { MnemonicCache.cache[words to (path.password ?: "nopass")] = it } diff --git a/api/src/main/java/com/getcode/model/Domain.kt b/libs/encryption/mnemonic/src/main/kotlin/com/getcode/model/Domain.kt similarity index 98% rename from api/src/main/java/com/getcode/model/Domain.kt rename to libs/encryption/mnemonic/src/main/kotlin/com/getcode/model/Domain.kt index b0f62b14d..71a6752f5 100644 --- a/api/src/main/java/com/getcode/model/Domain.kt +++ b/libs/encryption/mnemonic/src/main/kotlin/com/getcode/model/Domain.kt @@ -2,8 +2,6 @@ package com.getcode.model import android.net.Uri import androidx.core.net.toUri -import timber.log.Timber - class Domain private constructor( val relationshipHost: String, diff --git a/api/src/main/res/raw/english.txt b/libs/encryption/mnemonic/src/main/res/raw/english.txt similarity index 100% rename from api/src/main/res/raw/english.txt rename to libs/encryption/mnemonic/src/main/res/raw/english.txt diff --git a/libs/encryption/sha256/.gitignore b/libs/encryption/sha256/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/libs/encryption/sha256/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libs/encryption/sha256/build.gradle.kts b/libs/encryption/sha256/build.gradle.kts new file mode 100644 index 000000000..8df7540bf --- /dev/null +++ b/libs/encryption/sha256/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.encryption.sha256" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } +} + +dependencies { + implementation(Libs.grpc_okhttp) + implementation(Libs.grpc_kotlin) +} diff --git a/api/src/main/java/com/getcode/crypt/Sha256Hash.java b/libs/encryption/sha256/src/main/java/com/getcode/crypt/Sha256Hash.java similarity index 91% rename from api/src/main/java/com/getcode/crypt/Sha256Hash.java rename to libs/encryption/sha256/src/main/java/com/getcode/crypt/Sha256Hash.java index 2d76fa02e..db504bc8c 100644 --- a/api/src/main/java/com/getcode/crypt/Sha256Hash.java +++ b/libs/encryption/sha256/src/main/java/com/getcode/crypt/Sha256Hash.java @@ -17,6 +17,8 @@ * limitations under the License. */ +import com.google.common.base.Preconditions; +import com.google.common.io.BaseEncoding; import com.google.common.io.ByteStreams; import com.google.common.primitives.*; @@ -44,7 +46,7 @@ public class Sha256Hash implements Serializable, Comparable { private final byte[] bytes; private Sha256Hash(byte[] rawHashBytes) { - checkArgument(rawHashBytes.length == LENGTH); + Preconditions.checkArgument(rawHashBytes.length == LENGTH); this.bytes = rawHashBytes; } @@ -68,7 +70,7 @@ public static Sha256Hash wrap(byte[] rawHashBytes) { * hex string, or if it does not represent exactly 32 bytes */ public static Sha256Hash wrap(String hexString) { - return wrap(Utils.HEX.decode(hexString)); + return wrap(HEX.decode(hexString)); } /** @@ -79,7 +81,7 @@ public static Sha256Hash wrap(String hexString) { * @throws IllegalArgumentException if the given array length is not exactly 32 */ public static Sha256Hash wrapReversed(byte[] rawHashBytes) { - return wrap(Utils.reverseBytes(rawHashBytes)); + return wrap(reverseBytes(rawHashBytes)); } /** @@ -237,14 +239,14 @@ public int hashCode() { @Override public String toString() { - return Utils.HEX.encode(bytes); + return HEX.encode(bytes); } /** * Returns the bytes interpreted as a positive integer. */ public BigInteger toBigInteger() { - return Utils.bytesToBigInteger(bytes); + return bytesToBigInteger(bytes); } /** @@ -258,7 +260,7 @@ public byte[] getBytes() { * Returns a reversed copy of the internal byte array. */ public byte[] getReversedBytes() { - return Utils.reverseBytes(bytes); + return reverseBytes(bytes); } @Override @@ -273,4 +275,19 @@ public int compareTo(final Sha256Hash other) { } return 0; } + + private static final BaseEncoding HEX = BaseEncoding.base16().lowerCase(); + + private static byte[] reverseBytes(byte[] bytes) { + // We could use the XOR trick here but it's easier to understand if we don't. If we find this is really a + // performance issue the matter can be revisited. + byte[] buf = new byte[bytes.length]; + for (int i = 0; i < bytes.length; i++) + buf[i] = bytes[bytes.length - 1 - i]; + return buf; + } + + private static BigInteger bytesToBigInteger(byte[] bytes) { + return new BigInteger(1, bytes); + } } \ No newline at end of file diff --git a/libs/encryption/sha512/.gitignore b/libs/encryption/sha512/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/libs/encryption/sha512/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libs/encryption/sha512/build.gradle.kts b/libs/encryption/sha512/build.gradle.kts new file mode 100644 index 000000000..0e9950ded --- /dev/null +++ b/libs/encryption/sha512/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.encryption.sha512" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } +} + +dependencies { + implementation(Libs.grpc_okhttp) + implementation(Libs.grpc_kotlin) +} diff --git a/api/src/main/java/com/getcode/crypt/PBKDF2SHA512.java b/libs/encryption/sha512/src/main/java/com/getcode/crypt/PBKDF2SHA512.java similarity index 100% rename from api/src/main/java/com/getcode/crypt/PBKDF2SHA512.java rename to libs/encryption/sha512/src/main/java/com/getcode/crypt/PBKDF2SHA512.java diff --git a/libs/encryption/utils/.gitignore b/libs/encryption/utils/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/libs/encryption/utils/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libs/encryption/utils/build.gradle.kts b/libs/encryption/utils/build.gradle.kts new file mode 100644 index 000000000..c38e316d7 --- /dev/null +++ b/libs/encryption/utils/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.encryption.utils" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } +} + +dependencies { + implementation(project(":libs:crypto:kin")) + implementation(project(":libs:encryption:base58")) + implementation(project(":libs:encryption:ed25519")) + implementation(Libs.kotlinx_serialization_json) + implementation(Libs.timber) +} diff --git a/api/src/main/java/com/getcode/crypt/Utils.java b/libs/encryption/utils/src/main/java/com/getcode/utils/Utils.java similarity index 99% rename from api/src/main/java/com/getcode/crypt/Utils.java rename to libs/encryption/utils/src/main/java/com/getcode/utils/Utils.java index 22214d4ca..ccba8675b 100644 --- a/api/src/main/java/com/getcode/crypt/Utils.java +++ b/libs/encryption/utils/src/main/java/com/getcode/utils/Utils.java @@ -1,4 +1,4 @@ -package com.getcode.crypt; +package com.getcode.utils; /* * Copyright 2011 Google Inc. @@ -37,8 +37,6 @@ import static com.google.common.base.Preconditions.checkArgument; -import android.util.Log; - import timber.log.Timber; /** diff --git a/api/src/main/java/com/getcode/network/repository/Extensions.kt b/libs/encryption/utils/src/main/kotlin/com/getcode/utils/Extensions.kt similarity index 62% rename from api/src/main/java/com/getcode/network/repository/Extensions.kt rename to libs/encryption/utils/src/main/kotlin/com/getcode/utils/Extensions.kt index 964119f54..1144d4035 100644 --- a/api/src/main/java/com/getcode/network/repository/Extensions.kt +++ b/libs/encryption/utils/src/main/kotlin/com/getcode/utils/Extensions.kt @@ -1,48 +1,17 @@ -package com.getcode.network.repository +package com.getcode.utils import android.util.Base64 -import com.codeinc.gen.common.v1.Model import com.getcode.ed25519.Ed25519 -import com.getcode.solana.keys.Hash -import com.getcode.solana.keys.PublicKey -import com.getcode.solana.keys.fromUbytes -import com.google.protobuf.ByteString import com.getcode.vendor.Base58 -import com.google.protobuf.MessageLite -import timber.log.Timber -import java.io.ByteArrayOutputStream +import com.google.protobuf.ByteString import java.net.URLDecoder import java.net.URLEncoder import java.security.MessageDigest import java.security.NoSuchAlgorithmException -fun isMock() = false - fun List.toByteString(): ByteString = ByteString.copyFrom(this.toByteArray()) fun ByteArray.toByteString(): ByteString = ByteString.copyFrom(this) -fun ByteArray.toUserId(): Model.UserId { - return Model.UserId.newBuilder().setValue(this.toByteString()).build() -} - -fun String.toPhoneNumber(): Model.PhoneNumber { - return Model.PhoneNumber.newBuilder().setValue(this).build() -} - -fun List.toSolanaAccount(): Model.SolanaAccountId { - return Model.SolanaAccountId.newBuilder().setValue(this.toByteArray().toByteString()) - .build() -} - -fun ByteArray.toSolanaAccount(): Model.SolanaAccountId { - return Model.SolanaAccountId.newBuilder().setValue(this.toByteString()) - .build() -} - -fun ByteArray.toSignature(): Model.Signature { - return Model.Signature.newBuilder().setValue(this.toByteString()) - .build() -} val List.base58: String get() = Base58.encode(toByteArray()) @@ -50,27 +19,8 @@ val List.base58: String val ByteArray.base58: String get() = Base58.encode(this) -fun PublicKey.toIntentId(): Model.IntentId { - return Model.IntentId.newBuilder().setValue(this.byteArray.toByteString()).build() -} - -fun ByteArray.toPublicKey(): PublicKey { - return PublicKey(this.toList()) -} - -fun UByteArray.toPublicKey(): PublicKey { - return PublicKey.fromUbytes(this.toList()) -} - -fun ByteArray.toHash(): Hash { - return Hash(this.toList()) -} - -fun MessageLite.Builder.sign(owner: Ed25519.KeyPair): Model.Signature { - val bos = ByteArrayOutputStream() - this.buildPartial().writeTo(bos) - return Ed25519.sign(bos.toByteArray(), owner).toSignature() -} +val List.base64: String + get() = Base64.encodeToString(toByteArray(), Base64.NO_WRAP) fun String.decodeBase58(): ByteArray { return Base58.decode(this) @@ -84,6 +34,9 @@ fun ByteArray.decodeBase64(): ByteArray { return Base64.decode(this, Base64.NO_WRAP) } +val ByteArray.base64: String + get() = Base64.encodeToString(this, Base64.NO_WRAP) + fun ByteArray.encodeBase64(): String { return Base64.encodeToString(this, Base64.NO_WRAP) } @@ -144,9 +97,6 @@ fun Ed25519.KeyPair.getPublicKeyBase58(): String { return org.kin.sdk.base.tools.Base58.encode(publicKeyBytes) } -val Ed25519.KeyPair.publicKeyFromBytes: PublicKey - get() = publicKeyBytes.toPublicKey() - fun List.hexEncodedString(options: Set = emptySet()): String { val hexDigits = if (options.contains(HexEncodingOptions.Uppercase)) "0123456789ABCDEF" @@ -166,4 +116,5 @@ fun List.hexEncodedString(options: Set = emptySet()): sealed interface HexEncodingOptions { data object Uppercase: HexEncodingOptions -} \ No newline at end of file +} + diff --git a/libs/locale/.gitignore b/libs/locale/.gitignore new file mode 100644 index 000000000..9f2a07880 --- /dev/null +++ b/libs/locale/.gitignore @@ -0,0 +1,2 @@ +build/ +.gradle/ diff --git a/libs/locale/build.gradle.kts b/libs/locale/build.gradle.kts new file mode 100644 index 000000000..cd7b15d01 --- /dev/null +++ b/libs/locale/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.util.locale" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.compose_compiler + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } +} + +dependencies { + implementation(project(":libs:datetime")) + implementation(project(":libs:currency")) + api(Libs.androidx_annotation) + api(Libs.kotlin_stdlib) + api(Libs.kotlinx_coroutines_core) + api(Libs.kotlinx_coroutines_rx3) + + implementation(platform(Libs.compose_bom)) + implementation(Libs.compose_ui) + implementation(Libs.compose_foundation) +} diff --git a/app/src/main/java/com/getcode/util/locale/LocaleHelper.kt b/libs/locale/src/main/kotlin/com/getcode/util/locale/LocaleHelper.kt similarity index 100% rename from app/src/main/java/com/getcode/util/locale/LocaleHelper.kt rename to libs/locale/src/main/kotlin/com/getcode/util/locale/LocaleHelper.kt diff --git a/api/src/main/java/com/getcode/utils/LocaleUtils.kt b/libs/locale/src/main/kotlin/com/getcode/util/locale/LocaleUtils.kt similarity index 77% rename from api/src/main/java/com/getcode/utils/LocaleUtils.kt rename to libs/locale/src/main/kotlin/com/getcode/util/locale/LocaleUtils.kt index b3215c876..2555d70b9 100644 --- a/api/src/main/java/com/getcode/utils/LocaleUtils.kt +++ b/libs/locale/src/main/kotlin/com/getcode/util/locale/LocaleUtils.kt @@ -1,8 +1,7 @@ -package com.getcode.utils +package com.getcode.util.locale import android.content.Context import android.telephony.TelephonyManager -import com.getcode.model.CurrencyCode import com.getcode.model.RegionCode import java.util.* @@ -21,17 +20,17 @@ object LocaleUtils { val localCountry = context.resources.configuration.locale val networkCountryIsoCurrency = if (networkCountryIso.isNotBlank()) { - CurrencyCode.regionsCurrencies[RegionCode.tryValueOf(networkCountryIso)] + com.getcode.model.CurrencyCode.regionsCurrencies[RegionCode.tryValueOf(networkCountryIso)] } else null val simCountryIsoCurrency = if (simCountryIso.isNotBlank()) { - CurrencyCode.regionsCurrencies[RegionCode.tryValueOf(simCountryIso)] + com.getcode.model.CurrencyCode.regionsCurrencies[RegionCode.tryValueOf(simCountryIso)] } else null val localeIsoCurrency = runCatching { Currency.getInstance(localCountry).currencyCode }.getOrNull() - ?.let { CurrencyCode.tryValueOf(it) } - ?: CurrencyCode.USD + ?.let { com.getcode.model.CurrencyCode.tryValueOf(it) } + ?: com.getcode.model.CurrencyCode.USD return (networkCountryIsoCurrency ?: simCountryIsoCurrency ?: localeIsoCurrency).name } diff --git a/libs/logging/.gitignore b/libs/logging/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/libs/logging/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libs/logging/build.gradle.kts b/libs/logging/build.gradle.kts new file mode 100644 index 000000000..44e101b02 --- /dev/null +++ b/libs/logging/build.gradle.kts @@ -0,0 +1,64 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.libs.logging" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) + } + + buildTypes { + getByName("release") { + buildConfigField("Boolean", "NOTIFY_ERRORS", "true") + } + getByName("debug") { + buildConfigField( + "Boolean", + "NOTIFY_ERRORS", + tryReadProperty(rootProject.rootDir, "NOTIFY_ERRORS", "false") + ) + } + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } + + buildFeatures { + buildConfig = true + } +} + +dependencies { + api(Libs.timber) + implementation(Libs.bugsnag) + implementation(Libs.rxjava) + implementation(platform(Libs.firebase_bom)) + implementation(Libs.firebase_crashlytics) + implementation(Libs.grpc_kotlin) + implementation(Libs.sqlcipher) + implementation(project(":libs:messaging")) +} diff --git a/api/src/main/java/com/getcode/utils/ErrorUtils.kt b/libs/logging/src/main/kotlin/com/getcode/utils/ErrorUtils.kt similarity index 77% rename from api/src/main/java/com/getcode/utils/ErrorUtils.kt rename to libs/logging/src/main/kotlin/com/getcode/utils/ErrorUtils.kt index a6577cd1d..480d68e6e 100644 --- a/api/src/main/java/com/getcode/utils/ErrorUtils.kt +++ b/libs/logging/src/main/kotlin/com/getcode/utils/ErrorUtils.kt @@ -3,7 +3,7 @@ package com.getcode.utils import android.database.SQLException import com.bugsnag.android.Bugsnag import com.google.firebase.crashlytics.FirebaseCrashlytics -import com.getcode.api.BuildConfig +import com.getcode.libs.logging.BuildConfig import com.getcode.manager.TopBarManager import io.grpc.StatusRuntimeException import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException @@ -18,19 +18,27 @@ object ErrorUtils { private var isDisplayErrors = false fun setDisplayErrors(isDisplayErrors: Boolean) { - this.isDisplayErrors = isDisplayErrors + ErrorUtils.isDisplayErrors = isDisplayErrors } + private val ignoredErrors = listOf( + UnknownHostException::class, + TimeoutException::class, + TimeoutCancellationException::class, + ConnectException::class, + ) + fun handleError(throwable: Throwable) { if (isNetworkError(throwable) || isRuntimeError(throwable)) return val throwableCause: Throwable = - if (throwable.cause != null && (throwable is UndeliverableException || throwable is OnErrorNotImplementedException)) + if (throwable.cause != null && (throwable is UndeliverableException || throwable is OnErrorNotImplementedException || throwable is FlipchatServerError)) throwable.cause ?: throwable else throwable + Timber.e(throwable) + if ((isDisplayErrors && !isSuppressibleError(throwable))) { - Timber.e(throwable) TopBarManager.showMessage( "[Error] ${throwableCause.javaClass.simpleName}", @@ -41,10 +49,8 @@ object ErrorUtils { if ( BuildConfig.NOTIFY_ERRORS && - throwable !is UnknownHostException && - throwable !is TimeoutException && - throwable !is TimeoutCancellationException && - throwable !is ConnectException + ignoredErrors.none { it.isInstance(throwable) } && + ignoredErrors.none { it.isInstance(throwableCause) } ) { FirebaseCrashlytics.getInstance().recordException(throwable) if (Bugsnag.isStarted()) { @@ -67,4 +73,4 @@ object ErrorUtils { throwable is SQLException || throwable is net.sqlcipher.SQLException || throwable is SuppressibleException || throwable is TimeoutCancellationException } -data class SuppressibleException(override val message: String): Throwable(message) \ No newline at end of file +data class SuppressibleException(override val message: String) : Throwable(message) \ No newline at end of file diff --git a/libs/logging/src/main/kotlin/com/getcode/utils/FlipchatServerError.kt b/libs/logging/src/main/kotlin/com/getcode/utils/FlipchatServerError.kt new file mode 100644 index 000000000..64e63db53 --- /dev/null +++ b/libs/logging/src/main/kotlin/com/getcode/utils/FlipchatServerError.kt @@ -0,0 +1,9 @@ +package com.getcode.utils + +open class FlipchatServerError( + override val message: String? = null, + override val cause: Throwable? = null +) : Throwable( + message = "FlipchatServerError: $message", + cause = cause +) \ No newline at end of file diff --git a/api/src/main/java/com/getcode/utils/Logging.kt b/libs/logging/src/main/kotlin/com/getcode/utils/Logging.kt similarity index 99% rename from api/src/main/java/com/getcode/utils/Logging.kt rename to libs/logging/src/main/kotlin/com/getcode/utils/Logging.kt index 866b11840..1d8b60f61 100644 --- a/api/src/main/java/com/getcode/utils/Logging.kt +++ b/libs/logging/src/main/kotlin/com/getcode/utils/Logging.kt @@ -71,6 +71,7 @@ fun trace( metadata: MetadataBuilder.() -> Unit = {}, error: Throwable? = null ) { + error?.printStackTrace() val tagBlock = tag?.let { "[$it] " } val tree = if (tagBlock == null) Timber else Timber.tag(tagBlock) diff --git a/libs/messaging/.gitignore b/libs/messaging/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/libs/messaging/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libs/messaging/build.gradle.kts b/libs/messaging/build.gradle.kts new file mode 100644 index 000000000..f5c42ce0b --- /dev/null +++ b/libs/messaging/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.libs.messaging" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } +} + +dependencies { + api(Libs.kotlin_stdlib) + api(Libs.kotlinx_coroutines_core) + api(Libs.timber) + implementation(Libs.bugsnag) +} diff --git a/libs/messaging/src/main/kotlin/com/getcode/manager/BottomBarManager.kt b/libs/messaging/src/main/kotlin/com/getcode/manager/BottomBarManager.kt new file mode 100644 index 000000000..67c5685e3 --- /dev/null +++ b/libs/messaging/src/main/kotlin/com/getcode/manager/BottomBarManager.kt @@ -0,0 +1,158 @@ +package com.getcode.manager + +import kotlinx.coroutines.flow.* +import java.util.* + + +data class BottomBarAction( + val text: String, + val style: BottomBarManager.BottomBarButtonStyle = BottomBarManager.BottomBarButtonStyle.Filled, + val onClick: () -> Unit +) + +/** + * Class responsible for managing BottomBar messages to show on the screen + */ +object BottomBarManager { + data class BottomBarMessage( + val title: String = "", + val subtitle: String = "", + val actions: List = emptyList(), + val showCancel: Boolean = true, + val onClose: (fromAction: Boolean) -> Unit = {}, + val type: BottomBarMessageType = BottomBarMessageType.DESTRUCTIVE, + val isDismissible: Boolean = true, + val showScrim: Boolean = false, + val timeoutSeconds: Int? = null, + val id: Long = UUID.randomUUID().mostSignificantBits, + + @Deprecated( + message = "Use actions instead (e.g., the first item in actions)", + replaceWith = ReplaceWith("actions.firstOrNull()?.title ?: \"\""), + level = DeprecationLevel.WARNING + ) + val positiveText: String = "", + @Deprecated( + message = "Use actions instead (e.g., the first item in actions)", + replaceWith = ReplaceWith("actions.firstOrNull()?.style ?: \"\""), + level = DeprecationLevel.WARNING + ) + val positiveStyle: BottomBarButtonStyle = BottomBarButtonStyle.Filled, + @Deprecated( + message = "Use actions instead (e.g., the second item in actions)", + replaceWith = ReplaceWith("actions.getOrNull(1)?.title ?: \"\""), + level = DeprecationLevel.WARNING + ) + val negativeText: String = "", + @Deprecated( + message = "Use actions instead (e.g., the second item in actions)", + replaceWith = ReplaceWith("actions.getOrNull(1)?.style ?: \"\""), + level = DeprecationLevel.WARNING + ) + val negativeStyle: BottomBarButtonStyle = BottomBarButtonStyle.Filled10, + @Deprecated( + message = "Use actions instead (e.g., the third item in actions)", + replaceWith = ReplaceWith("actions.getOrNull(2)?.title"), + level = DeprecationLevel.WARNING + ) + val tertiaryText: String? = null, + @Deprecated( + message = "Use actions instead (e.g., the first item in actions)", + replaceWith = ReplaceWith("actions.firstOrNull()?.onClick ?: { }"), + level = DeprecationLevel.WARNING + ) + val onPositive: () -> Unit = { }, + @Deprecated( + message = "Use actions instead (e.g., the second item in actions)", + replaceWith = ReplaceWith("actions.getOrNull(1)?.onClick ?: { }"), + level = DeprecationLevel.WARNING + ) + val onNegative: () -> Unit = {}, + @Deprecated( + message = "Use actions instead (e.g., the third item in actions)", + replaceWith = ReplaceWith("actions.getOrNull(2)?.onClick ?: { }"), + level = DeprecationLevel.WARNING + ) + val onTertiary: () -> Unit = {}, + ) { + constructor( + title: String = "", + subtitle: String = "", + positiveText: String, + positiveStyle: BottomBarButtonStyle = BottomBarButtonStyle.Filled, + negativeText: String = "", + negativeStyle: BottomBarButtonStyle = BottomBarButtonStyle.Filled10, + tertiaryText: String? = null, + onPositive: () -> Unit, + onNegative: () -> Unit = {}, + onTertiary: () -> Unit = {}, + onClose: (fromAction: Boolean) -> Unit = {}, + type: BottomBarMessageType = BottomBarMessageType.DESTRUCTIVE, + isDismissible: Boolean = true, + showScrim: Boolean = false, + timeoutSeconds: Int? = null, + id: Long = UUID.randomUUID().mostSignificantBits + ) : this( + title = title, + subtitle = subtitle, + actions = buildList { + if (positiveText.isNotBlank()) { + add(BottomBarAction(positiveText, positiveStyle, onPositive)) + } + + if (negativeText.isNotBlank()) { + add(BottomBarAction(negativeText, negativeStyle, onNegative)) + } + }, + showCancel = tertiaryText != null, + onClose = onClose, + type = type, + isDismissible = isDismissible, + showScrim = showScrim, + timeoutSeconds = timeoutSeconds, + id = id, + positiveText = positiveText, + positiveStyle = positiveStyle, + negativeText = negativeText, + negativeStyle = negativeStyle, + tertiaryText = tertiaryText, + onPositive = onPositive, + onNegative = onNegative, + onTertiary = onTertiary + ) + } + + private val _messages: MutableStateFlow> = MutableStateFlow( + listOf() + ) + val messages: StateFlow> get() = _messages.asStateFlow() + + fun showMessage(bottomBarMessage: BottomBarMessage) { + _messages.update { currentMessages -> + currentMessages + bottomBarMessage + } + } + + fun setMessageShown(messageId: Long) { + _messages.update { currentMessages -> + currentMessages.filterNot { it.id == messageId } + } + } + + fun clear() = _messages.update { listOf() } + + fun clearByType(type: BottomBarMessageType) = _messages.update { it.filterNot { m -> m.type == type } } + + enum class BottomBarMessageType { DESTRUCTIVE, REMOTE_SEND, THEMED } + + enum class BottomBarActionType { + Positive, + Negative, + Tertiary + } + + enum class BottomBarButtonStyle { + Filled, Filled10 + } + +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/manager/TopBarManager.kt b/libs/messaging/src/main/kotlin/com/getcode/manager/TopBarManager.kt similarity index 100% rename from api/src/main/java/com/getcode/manager/TopBarManager.kt rename to libs/messaging/src/main/kotlin/com/getcode/manager/TopBarManager.kt diff --git a/libs/models/.gitignore b/libs/models/.gitignore new file mode 100644 index 000000000..9f2a07880 --- /dev/null +++ b/libs/models/.gitignore @@ -0,0 +1,2 @@ +build/ +.gradle/ diff --git a/libs/models/build.gradle.kts b/libs/models/build.gradle.kts new file mode 100644 index 000000000..5392b90a9 --- /dev/null +++ b/libs/models/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.libs.models" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } +} + +dependencies { + implementation(project(":libs:encryption:base58")) + implementation(project(":libs:encryption:ed25519")) + implementation(project(":libs:encryption:keys")) + implementation(project(":libs:encryption:utils")) + implementation(project(":libs:crypto:kin")) + implementation(project(":libs:currency")) + implementation(Libs.kotlinx_serialization_json) + + implementation(Libs.androidx_room_runtime) + implementation(Libs.androidx_room_ktx) + + api(Libs.sodium_bindings) + implementation(project(":definitions:code:models")) +} diff --git a/api/src/main/java/com/getcode/model/Cursor.kt b/libs/models/src/main/kotlin/com/getcode/model/Cursor.kt similarity index 100% rename from api/src/main/java/com/getcode/model/Cursor.kt rename to libs/models/src/main/kotlin/com/getcode/model/Cursor.kt diff --git a/api/src/main/java/com/getcode/model/EncryptedData.kt b/libs/models/src/main/kotlin/com/getcode/model/EncryptedData.kt similarity index 84% rename from api/src/main/java/com/getcode/model/EncryptedData.kt rename to libs/models/src/main/kotlin/com/getcode/model/EncryptedData.kt index 8a89ba888..e417edd21 100644 --- a/api/src/main/java/com/getcode/model/EncryptedData.kt +++ b/libs/models/src/main/kotlin/com/getcode/model/EncryptedData.kt @@ -2,12 +2,12 @@ package com.getcode.model import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.solana.keys.PublicKey -import com.getcode.solana.keys.boxOpen -import com.getcode.solana.keys.encryptionPrivateKey +import com.getcode.utils.serializer.PublicKeyAsStringSerializer import kotlinx.serialization.Serializable @Serializable data class EncryptedData( + @Serializable(with = PublicKeyAsStringSerializer::class) val peerPublicKey: PublicKey, val nonce: List, val encryptedData: List diff --git a/libs/models/src/main/kotlin/com/getcode/model/Fiat.kt b/libs/models/src/main/kotlin/com/getcode/model/Fiat.kt new file mode 100644 index 000000000..e35cb756b --- /dev/null +++ b/libs/models/src/main/kotlin/com/getcode/model/Fiat.kt @@ -0,0 +1,82 @@ +package com.getcode.model + +import kotlinx.serialization.Serializable + + + +@Serializable +data class Fiat( + val currency: CurrencyCode, + val amount: Double, +): Value { + + companion object { + fun fromString(currency: CurrencyCode, amountString: String): Fiat? { + val amount = amountString.toDoubleOrNull() ?: return null + return Fiat( + currency = currency, + amount = amount + ) + } + } +} + +@Serializable +sealed interface GenericAmount { + + val currencyCode: CurrencyCode + + @Serializable + data class Exact(val amount: KinAmount): GenericAmount { + override val currencyCode: CurrencyCode = amount.rate.currency + } + + @Serializable + data class Partial(val fiat: Fiat): GenericAmount { + override val currencyCode: CurrencyCode = fiat.currency + } + + fun amountUsing(rate: Rate): KinAmount { + return when (this) { + is Exact -> amount + is Partial -> KinAmount.fromFiatAmount(fiat.amount, rate) + } + } +} + +fun KinAmount.Companion.fromFiatAmount(kin: Kin, fiat: Double, fx: Double, currencyCode: CurrencyCode): KinAmount { + return KinAmount( + kin = kin.inflating(), + fiat = fiat, + rate = Rate( + fx = fx, + currency = currencyCode + ) + ) +} + +fun KinAmount.Companion.fromFiatAmount(fiat: Double, fx: Double, currencyCode: CurrencyCode): KinAmount { + return fromFiatAmount( + kin = Kin.fromFiat(fiat = fiat, fx = fx), + fiat = fiat, + fx = fx, + currencyCode = currencyCode + ) +} + +fun KinAmount.Companion.fromFiatAmount(fiat: Double, rate: Rate): KinAmount { + return fromFiatAmount( + kin = Kin.fromFiat(fiat = fiat, fx = rate.fx), + fiat = fiat, + fx = rate.fx, + currencyCode = rate.currency + ) +} + +fun KinAmount.Companion.fromFiatAmount(fiat: Fiat, rate: Rate): KinAmount { + return KinAmount( + kin = Kin.fromFiat(fiat = fiat.amount, fx = rate.fx), + fiat = fiat.amount, + rate = rate + ) +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/ID.kt b/libs/models/src/main/kotlin/com/getcode/model/ID.kt similarity index 86% rename from api/src/main/java/com/getcode/model/ID.kt rename to libs/models/src/main/kotlin/com/getcode/model/ID.kt index 6341a2ad8..b59a21031 100644 --- a/api/src/main/java/com/getcode/model/ID.kt +++ b/libs/models/src/main/kotlin/com/getcode/model/ID.kt @@ -1,11 +1,13 @@ package com.getcode.model -import com.getcode.network.repository.hexEncodedString +import com.getcode.utils.hexEncodedString import java.nio.ByteBuffer import java.util.UUID typealias ID = List +val NoId: ID = emptyList() + val ID.uuid: UUID? get() { if (size != 16) return null @@ -20,4 +22,3 @@ val ID.description: String get() = uuid?.toString() ?: hexEncodedString() - diff --git a/libs/models/src/main/kotlin/com/getcode/model/PointerStatus.kt b/libs/models/src/main/kotlin/com/getcode/model/PointerStatus.kt new file mode 100644 index 000000000..dc42aacc7 --- /dev/null +++ b/libs/models/src/main/kotlin/com/getcode/model/PointerStatus.kt @@ -0,0 +1,15 @@ +package com.getcode.model + +import com.getcode.model.chat.MessageStatus +import com.getcode.utils.timestamp +import java.util.UUID + +data class PointerStatus( + val messageId: UUID, + val memberId: ID, + val messageStatus: MessageStatus, +) { + + val timestamp: Long? + get() = messageId.timestamp +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/SocialUser.kt b/libs/models/src/main/kotlin/com/getcode/model/SocialUser.kt similarity index 100% rename from api/src/main/java/com/getcode/model/SocialUser.kt rename to libs/models/src/main/kotlin/com/getcode/model/SocialUser.kt diff --git a/api/src/main/java/com/getcode/solana/keys/Sodium.kt b/libs/models/src/main/kotlin/com/getcode/model/Sodium.kt similarity index 88% rename from api/src/main/java/com/getcode/solana/keys/Sodium.kt rename to libs/models/src/main/kotlin/com/getcode/model/Sodium.kt index 3e696e45b..8f77ca005 100644 --- a/api/src/main/java/com/getcode/solana/keys/Sodium.kt +++ b/libs/models/src/main/kotlin/com/getcode/model/Sodium.kt @@ -1,9 +1,10 @@ -package com.getcode.solana.keys +package com.getcode.model import android.util.Base64 +import com.getcode.ed25519.Ed25519 import com.getcode.ed25519.Ed25519.KeyPair -import com.getcode.network.repository.toPublicKey -import com.getcode.vendor.Base58 +import com.getcode.solana.keys.PrivateKey +import com.getcode.solana.keys.PublicKey import com.ionspin.kotlin.crypto.box.Box import com.ionspin.kotlin.crypto.secretbox.SecretBox import com.ionspin.kotlin.crypto.signature.Signature @@ -107,3 +108,18 @@ sealed class SodiumError { data class EncryptionFailed(val root: Throwable? = null): Throwable(cause = root) data class DecryptionFailed(val root: Throwable? = null): Throwable(cause = root) } + + +fun ByteArray.toPublicKey(): PublicKey { + return PublicKey(this.toList()) +} + +fun UByteArray.toPublicKey(): PublicKey { + return PublicKey.fromUbytes(this.toList()) +} + +fun ByteArray.toHash(): com.getcode.solana.keys.Hash { + return com.getcode.solana.keys.Hash(this.toList()) +} + +fun PublicKey.Companion.generate(): PublicKey = Ed25519.createSeed32().toPublicKey() \ No newline at end of file diff --git a/libs/models/src/main/kotlin/com/getcode/model/TwitterUser.kt b/libs/models/src/main/kotlin/com/getcode/model/TwitterUser.kt new file mode 100644 index 000000000..769f465ae --- /dev/null +++ b/libs/models/src/main/kotlin/com/getcode/model/TwitterUser.kt @@ -0,0 +1,39 @@ +package com.getcode.model + +import android.webkit.MimeTypeMap +import com.getcode.solana.keys.PublicKey +import com.getcode.utils.serializer.PublicKeyAsStringSerializer +import kotlinx.serialization.Serializable + +@Serializable +data class TwitterUser( + override val username: String, + @Serializable(with = PublicKeyAsStringSerializer::class) + override val tipAddress: PublicKey, + override val imageUrl: String?, + val displayName: String, + val followerCount: Int, + val verificationStatus: VerificationStatus, + override val costOfFriendship: Fiat, + override val isFriend: Boolean, + override val chatId: ID, +): SocialUser { + + override val platform: String = "X" + + override val imageUrlSanitized: String? + get() { + val url = imageUrl ?: return null + val extension = MimeTypeMap.getFileExtensionFromUrl(url) + val urlWithoutExtension = url.removeSuffix(extension) + val urlWithoutType = urlWithoutExtension.substringBeforeLast("_") + + return urlWithoutType.plus(".$extension") + } + + enum class VerificationStatus { + none, blue, business, government, unknown + } + + companion object +} \ No newline at end of file diff --git a/libs/models/src/main/kotlin/com/getcode/model/chat/AnnouncementAction.kt b/libs/models/src/main/kotlin/com/getcode/model/chat/AnnouncementAction.kt new file mode 100644 index 000000000..497565c4d --- /dev/null +++ b/libs/models/src/main/kotlin/com/getcode/model/chat/AnnouncementAction.kt @@ -0,0 +1,5 @@ +package com.getcode.model.chat + +enum class AnnouncementAction { + Unknown, Share +} \ No newline at end of file diff --git a/libs/models/src/main/kotlin/com/getcode/model/chat/ChatMessage.kt b/libs/models/src/main/kotlin/com/getcode/model/chat/ChatMessage.kt new file mode 100644 index 000000000..8a0186371 --- /dev/null +++ b/libs/models/src/main/kotlin/com/getcode/model/chat/ChatMessage.kt @@ -0,0 +1,31 @@ +package com.getcode.model.chat + +import com.getcode.model.Cursor +import com.getcode.model.ID +import kotlinx.serialization.Serializable + +/** + * A message in a chat + * + * @param id Globally unique ID for this message + * This is a time based UUID in v2 + * @param senderId The chat member that sent the message. + * @param cursor Cursor value for this message for reference in a paged GetMessagesRequest + * @param dateMillis Timestamp this message was generated at + * @param contents Ordered message content. A message may have more than one piece of content. + */ +@Serializable +data class ChatMessage( + val id: ID, // time based UUID in v2 + val senderId: ID, + val isFromSelf: Boolean, + val cursor: Cursor = id, + val dateMillis: Long, + val contents: List, + val wasSentOffStage: Boolean = false, +) { + val hasEncryptedContent: Boolean + get() { + return contents.firstOrNull { it is MessageContent.SodiumBox } != null + } +} \ No newline at end of file diff --git a/libs/models/src/main/kotlin/com/getcode/model/chat/ChatType.kt b/libs/models/src/main/kotlin/com/getcode/model/chat/ChatType.kt new file mode 100644 index 000000000..4c2ab0556 --- /dev/null +++ b/libs/models/src/main/kotlin/com/getcode/model/chat/ChatType.kt @@ -0,0 +1,11 @@ +package com.getcode.model.chat + +enum class ChatType { + Unknown, + TwoWay, + GroupChat; + + companion object { + val types = entries + } +} \ No newline at end of file diff --git a/libs/models/src/main/kotlin/com/getcode/model/chat/MessageContent.kt b/libs/models/src/main/kotlin/com/getcode/model/chat/MessageContent.kt new file mode 100644 index 000000000..6310203b8 --- /dev/null +++ b/libs/models/src/main/kotlin/com/getcode/model/chat/MessageContent.kt @@ -0,0 +1,525 @@ +package com.getcode.model.chat + +import com.getcode.model.EncryptedData +import com.getcode.model.GenericAmount +import com.getcode.model.ID +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +sealed interface MessageContent { + val kind: Int + val isFromSelf: Boolean + + val content: String + + data class Unknown( + override val isFromSelf: Boolean, + ): MessageContent { + override val kind: Int = -1 + override val content: String + get() = "unknown" + } + + @Serializable + data class Localized( + val value: String, + override val isFromSelf: Boolean, + ) : MessageContent { + override val kind: Int = 0 + + override fun hashCode(): Int { + var result = value.hashCode() + result += isFromSelf.hashCode() + result += kind.hashCode() + + return result + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Localized + + if (value != other.value) return false + if (isFromSelf != other.isFromSelf) return false + if (kind != other.kind) return false + + return true + } + + override val content: String = value + } + + @Serializable + data class RawText( + val value: String, + override val isFromSelf: Boolean, + ) : MessageContent { + override val kind: Int = 1 + + override fun hashCode(): Int { + var result = value.hashCode() + result += isFromSelf.hashCode() + result += kind.hashCode() + + return result + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RawText + + if (value != other.value) return false + if (isFromSelf != other.isFromSelf) return false + if (kind != other.kind) return false + + return true + } + + override val content: String = value + } + + @Serializable + data class Exchange( + val amount: GenericAmount, + val verb: Verb, + val reference: Reference?, + override val isFromSelf: Boolean, + ) : MessageContent { + override val kind: Int = 2 + + override fun hashCode(): Int { + var result = amount.hashCode() + result += verb.hashCode() + result += (reference?.hashCode() ?: 0) + result += isFromSelf.hashCode() + result += kind.hashCode() + + return result + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Exchange + + if (amount != other.amount) return false + if (verb != other.verb) return false + if (reference != other.reference) return false + if (isFromSelf != other.isFromSelf) return false + if (kind != other.kind) return false + + return true + } + + @Serializable + internal data class Content( + val amount: GenericAmount, + val verb: Verb, + val reference: Reference?, + ) + + @Transient + override val content: String = Json.encodeToString(Content(amount, verb, reference)) + } + + @Serializable + data class SodiumBox( + val data: EncryptedData, + override val isFromSelf: Boolean, + ) : MessageContent { + override val kind: Int = 3 + + override fun hashCode(): Int { + var result = data.hashCode() + result += isFromSelf.hashCode() + result += kind.hashCode() + + return result + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SodiumBox + + if (data != other.data) return false + if (isFromSelf != other.isFromSelf) return false + if (kind != other.kind) return false + + return true + } + + @Serializable + internal data class Content( + val data: EncryptedData, + ) + + @Transient + override val content: String = Json.encodeToString(Content(data)) + } + + @Serializable + data class Announcement( + val value: String, + override val isFromSelf: Boolean, + ) : MessageContent { + override val kind: Int = 4 + + override fun hashCode(): Int { + var result = value.hashCode() + result += isFromSelf.hashCode() + result += kind.hashCode() + + return result + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Announcement + + if (value != other.value) return false + if (isFromSelf != other.isFromSelf) return false + if (kind != other.kind) return false + + return true + } + + override val content: String = value + } + + @Serializable + data class Decrypted( + val data: String, + override val isFromSelf: Boolean, + ) : MessageContent { + override val kind: Int = 6 + + override fun hashCode(): Int { + var result = data.hashCode() + result += isFromSelf.hashCode() + result += kind.hashCode() + + return result + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Decrypted + + if (data != other.data) return false + if (isFromSelf != other.isFromSelf) return false + if (kind != other.kind) return false + + return true + } + + override val content: String = data + } + + @Serializable + data class Reaction( + val emoji: String, + val originalMessageId: ID, + override val isFromSelf: Boolean, + ) : MessageContent { + override val kind: Int = 7 + + override fun hashCode(): Int { + var result = emoji.hashCode() + result += originalMessageId.hashCode() + result += isFromSelf.hashCode() + result += kind.hashCode() + + return result + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Reaction + + if (emoji != other.emoji) return false + if (originalMessageId != other.originalMessageId) return false + if (isFromSelf != other.isFromSelf) return false + if (kind != other.kind) return false + + return true + } + + @Serializable + internal data class Content( + val emoji: String, + val originalMessageId: ID, + ) + + @Transient + override val content: String = Json.encodeToString(Content(emoji, originalMessageId)) + } + + @Serializable + data class Reply( + val text: String, + val originalMessageId: ID, + override val isFromSelf: Boolean, + ) : MessageContent { + override val kind: Int = 8 + + override fun hashCode(): Int { + var result = text.hashCode() + result += originalMessageId.hashCode() + result += isFromSelf.hashCode() + result += kind.hashCode() + + return result + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Reply + + if (text != other.text) return false + if (originalMessageId != other.originalMessageId) return false + if (isFromSelf != other.isFromSelf) return false + if (kind != other.kind) return false + + return true + } + + @Serializable + internal data class Content( + val text: String, + val originalMessageId: ID, + ) + + @Transient + override val content: String = Json.encodeToString(Content(text, originalMessageId)) + } + + @Serializable + data class DeletedMessage( + val originalMessageId: ID, + val messageDeleter: ID, + override val isFromSelf: Boolean, + ) : MessageContent { + override val kind: Int = 9 + + override fun hashCode(): Int { + var result = originalMessageId.hashCode() + result += messageDeleter.hashCode() + result += isFromSelf.hashCode() + result += kind.hashCode() + + return result + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DeletedMessage + + if (originalMessageId != other.originalMessageId) return false + if (messageDeleter != other.messageDeleter) return false + if (isFromSelf != other.isFromSelf) return false + if (kind != other.kind) return false + + return true + } + + @Serializable + internal data class Content( + val originalMessageId: ID, + val messageDeleter: ID, + ) + + @Transient + override val content: String = Json.encodeToString(Content(originalMessageId, messageDeleter)) + } + + @Serializable + data class MessageTip( + val originalMessageId: ID, + val tipperId: ID, + val amountInQuarks: Long, + override val isFromSelf: Boolean, + ) : MessageContent { + override val kind: Int = 10 + + override fun hashCode(): Int { + var result = originalMessageId.hashCode() + result += tipperId.hashCode() + result += amountInQuarks.hashCode() + result += isFromSelf.hashCode() + result += kind.hashCode() + + return result + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MessageTip + + if (originalMessageId != other.originalMessageId) return false + if (tipperId != other.tipperId) return false + if (amountInQuarks != other.amountInQuarks) return false + if (isFromSelf != other.isFromSelf) return false + if (kind != other.kind) return false + + return true + } + + @Serializable + internal data class Content( + val originalMessageId: ID, + val tipperId: ID, + val amountInQuarks: Long, + ) + + @Transient + override val content: String = Json.encodeToString(Content(originalMessageId, tipperId, amountInQuarks)) + } + + @Serializable + data class MessageInReview( + val originalMessageId: ID, + val isApproved: Boolean, + override val isFromSelf: Boolean, + ) : MessageContent { + override val kind: Int = 11 + + override fun hashCode(): Int { + var result = originalMessageId.hashCode() + result += isApproved.hashCode() + result += isFromSelf.hashCode() + result += kind.hashCode() + + return result + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MessageInReview + + if (originalMessageId != other.originalMessageId) return false + if (isApproved != other.isApproved) return false + if (isFromSelf != other.isFromSelf) return false + if (kind != other.kind) return false + + return true + } + + @Serializable + internal data class Content( + val originalMessageId: ID, + val isApproved: Boolean, + ) + + @Transient + override val content: String = Json.encodeToString(Content(originalMessageId, isApproved)) + } + + @Serializable + data class ActionableAnnouncement( + val keyOrText: String, + val action: AnnouncementAction, + override val isFromSelf: Boolean, + ) : MessageContent { + override val kind: Int = 12 + + override fun hashCode(): Int { + var result = keyOrText.hashCode() + result += action.hashCode() + result += isFromSelf.hashCode() + result += kind.hashCode() + + return result + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ActionableAnnouncement + + if (keyOrText != other.keyOrText) return false + if (action != other.action) return false + if (isFromSelf != other.isFromSelf) return false + if (kind != other.kind) return false + + return true + } + + @Serializable + internal data class Content( + val keyOrText: String, + val action: AnnouncementAction, + ) + + @Transient + override val content: String = Json.encodeToString(Content(keyOrText, action)) + } + + companion object { + fun fromData(type: Int, content: String, isFromSelf: Boolean): MessageContent { + return when (type) { + 0 -> Localized(content, isFromSelf) + 1 -> RawText(content, isFromSelf) + 2 -> { + val data = Json.decodeFromString(content) + Exchange(data.amount, data.verb, data.reference, isFromSelf) + } + 3 -> { + val data = Json.decodeFromString(content) + SodiumBox(data.data, isFromSelf) + } + 4 -> Announcement(content, isFromSelf) + 6 -> Decrypted(content, isFromSelf) + 7 -> { + val data = Json.decodeFromString(content) + Reaction(data.emoji, data.originalMessageId, isFromSelf) + } + 8 -> { + val data = Json.decodeFromString(content) + Reply(data.text, data.originalMessageId, isFromSelf) + } + 9 -> { + val data = Json.decodeFromString(content) + DeletedMessage(data.originalMessageId, data.messageDeleter, isFromSelf) + } + 10 -> { + val data = Json.decodeFromString(content) + MessageTip(data.originalMessageId, data.tipperId, data.amountInQuarks, isFromSelf) + } + 11 -> { + val data = Json.decodeFromString(content) + MessageInReview(data.originalMessageId, data.isApproved, isFromSelf) + } + 12 -> { + val data = Json.decodeFromString(content) + ActionableAnnouncement(data.keyOrText, data.action, isFromSelf) + } + else -> Unknown(isFromSelf) + } + } + } +} \ No newline at end of file diff --git a/libs/models/src/main/kotlin/com/getcode/model/chat/MessageStatus.kt b/libs/models/src/main/kotlin/com/getcode/model/chat/MessageStatus.kt new file mode 100644 index 000000000..095dde609 --- /dev/null +++ b/libs/models/src/main/kotlin/com/getcode/model/chat/MessageStatus.kt @@ -0,0 +1,14 @@ +package com.getcode.model.chat + +enum class MessageStatus { + Sent, Delivered, Read, Unknown; + + fun isOutgoing() = when (this) { + Sent, + Delivered -> true + + else -> false + } + + fun isValid() = this != Unknown +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/chat/Platform.kt b/libs/models/src/main/kotlin/com/getcode/model/chat/Platform.kt similarity index 67% rename from api/src/main/java/com/getcode/model/chat/Platform.kt rename to libs/models/src/main/kotlin/com/getcode/model/chat/Platform.kt index df2fa145a..dd4c62915 100644 --- a/api/src/main/java/com/getcode/model/chat/Platform.kt +++ b/libs/models/src/main/kotlin/com/getcode/model/chat/Platform.kt @@ -1,16 +1,10 @@ package com.getcode.model.chat -import com.codeinc.gen.chat.v2.ChatService - enum class Platform { Unknown, Twitter; companion object { - operator fun invoke(proto: ChatService.Platform): Platform { - return runCatching { entries[proto.ordinal] }.getOrNull() ?: Unknown - } - fun named(name: String): Platform { val normalizedName = name.lowercase() return entries.firstOrNull { diff --git a/api/src/main/java/com/getcode/model/chat/Pointer.kt b/libs/models/src/main/kotlin/com/getcode/model/chat/Pointer.kt similarity index 54% rename from api/src/main/java/com/getcode/model/chat/Pointer.kt rename to libs/models/src/main/kotlin/com/getcode/model/chat/Pointer.kt index 01baf0986..6e109c339 100644 --- a/api/src/main/java/com/getcode/model/chat/Pointer.kt +++ b/libs/models/src/main/kotlin/com/getcode/model/chat/Pointer.kt @@ -1,11 +1,9 @@ package com.getcode.model.chat -import com.codeinc.gen.chat.v2.ChatService import com.getcode.model.ID -import com.getcode.model.uuid import com.getcode.utils.serializer.UUIDSerializer -import kotlinx.serialization.Serializable import java.util.UUID +import kotlinx.serialization.Serializable @Serializable sealed interface Pointer { @@ -38,19 +36,5 @@ sealed interface Pointer { override val messageId: UUID? ) : Pointer - companion object { - operator fun invoke(proto: ChatService.Pointer): Pointer { - val memberId = proto.memberId.value.toList() - val messageId = proto.value.value.toList().uuid ?: return Unknown(memberId) - - return when (proto.type) { - ChatService.PointerType.UNKNOWN_POINTER_TYPE -> Unknown(proto.memberId.value.toList()) - ChatService.PointerType.READ -> Read(memberId, messageId) - ChatService.PointerType.DELIVERED -> Delivered(memberId, messageId) - ChatService.PointerType.SENT -> Sent(memberId, messageId) - ChatService.PointerType.UNRECOGNIZED -> Unknown(memberId) - else -> Unknown(memberId) - } - } - } + companion object } \ No newline at end of file diff --git a/libs/models/src/main/kotlin/com/getcode/model/chat/Reference.kt b/libs/models/src/main/kotlin/com/getcode/model/chat/Reference.kt new file mode 100644 index 000000000..c7f44d539 --- /dev/null +++ b/libs/models/src/main/kotlin/com/getcode/model/chat/Reference.kt @@ -0,0 +1,23 @@ +package com.getcode.model.chat + +import com.getcode.model.ID +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient + +/** + * An ID that can be referenced to the source of the exchange of Kin + */ +@Serializable +sealed interface Reference { + @Serializable + data object NoneSet: Reference + @Serializable + data class IntentId(val id: ID): Reference + @Serializable + data class Signature(val bytes: List): Reference { + @Transient + val signature = com.getcode.solana.keys.Signature(bytes) + } + + companion object +} diff --git a/libs/models/src/main/kotlin/com/getcode/model/chat/Sender.kt b/libs/models/src/main/kotlin/com/getcode/model/chat/Sender.kt new file mode 100644 index 000000000..78667d22d --- /dev/null +++ b/libs/models/src/main/kotlin/com/getcode/model/chat/Sender.kt @@ -0,0 +1,32 @@ +package com.getcode.model.chat + +import com.getcode.model.ID + +data class Sender( + val id: ID? = null, + val profileImage: String? = null, + val displayName: String? = null, + val isHost: Boolean = false, + val isSelf: Boolean = false, + val isFullMember: Boolean = false, + val isBlocked: Boolean = false, +) + +data class Deleter( + val id: ID? = null, + val isHost: Boolean = false, + val isSelf: Boolean = false, +) + +data class MinimalMember( + val id: ID? = null, + private val profileImageUrl: String? = null, + val displayName: String? = null, + val isHost: Boolean = false, + val isSelf: Boolean = false, + val canSpeak: Boolean = false, +) { + val imageData: Any? = profileImageUrl.nullIfEmpty() ?: id +} + +private fun String?.nullIfEmpty() = if (this?.isEmpty() == true) null else this diff --git a/api/src/main/java/com/getcode/model/chat/Title.kt b/libs/models/src/main/kotlin/com/getcode/model/chat/Title.kt similarity index 100% rename from api/src/main/java/com/getcode/model/chat/Title.kt rename to libs/models/src/main/kotlin/com/getcode/model/chat/Title.kt diff --git a/libs/models/src/main/kotlin/com/getcode/model/chat/Verb.kt b/libs/models/src/main/kotlin/com/getcode/model/chat/Verb.kt new file mode 100644 index 000000000..86b7acc0e --- /dev/null +++ b/libs/models/src/main/kotlin/com/getcode/model/chat/Verb.kt @@ -0,0 +1,70 @@ +package com.getcode.model.chat + +import kotlinx.serialization.Serializable + +@Serializable +sealed interface Verb { + val increasesBalance: Boolean + + @Serializable + data object Unknown : Verb { + override val increasesBalance: Boolean = false + } + + @Serializable + data object Gave : Verb { + override val increasesBalance: Boolean = false + } + + @Serializable + data object Received : Verb { + override val increasesBalance: Boolean = true + } + + @Serializable + data object Withdrew : Verb { + override val increasesBalance: Boolean = false + } + + @Serializable + data object Deposited : Verb { + override val increasesBalance: Boolean = true + } + + @Serializable + data object Sent : Verb { + override val increasesBalance: Boolean = false + } + + @Serializable + data object Returned : Verb { + override val increasesBalance: Boolean = true + } + + @Serializable + data object Spent : Verb { + override val increasesBalance: Boolean = false + } + + @Serializable + data object Paid : Verb { + override val increasesBalance: Boolean = false + } + + @Serializable + data object Purchased : Verb { + override val increasesBalance: Boolean = true + } + + @Serializable + data object ReceivedTip : Verb { + override val increasesBalance: Boolean = true + } + + @Serializable + data object SentTip : Verb { + override val increasesBalance: Boolean = false + } + + companion object +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/utils/UUID.kt b/libs/models/src/main/kotlin/com/getcode/utils/UUID.kt similarity index 100% rename from api/src/main/java/com/getcode/utils/UUID.kt rename to libs/models/src/main/kotlin/com/getcode/utils/UUID.kt diff --git a/api/src/main/java/com/getcode/utils/serializer/UUIDSerializer.kt b/libs/models/src/main/kotlin/com/getcode/utils/serializer/UUIDSerializer.kt similarity index 100% rename from api/src/main/java/com/getcode/utils/serializer/UUIDSerializer.kt rename to libs/models/src/main/kotlin/com/getcode/utils/serializer/UUIDSerializer.kt diff --git a/libs/network/connectivity/.gitignore b/libs/network/connectivity/.gitignore new file mode 100644 index 000000000..9f2a07880 --- /dev/null +++ b/libs/network/connectivity/.gitignore @@ -0,0 +1,2 @@ +build/ +.gradle/ diff --git a/common/components/build.gradle.kts b/libs/network/connectivity/build.gradle.kts similarity index 62% rename from common/components/build.gradle.kts rename to libs/network/connectivity/build.gradle.kts index cf317cb68..6c0f7cef1 100644 --- a/common/components/build.gradle.kts +++ b/libs/network/connectivity/build.gradle.kts @@ -1,10 +1,12 @@ plugins { id(Plugins.android_library) id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) } android { - namespace = "com.getcode.ui.components" + namespace = "${Android.codeNamespace}.libs.network" compileSdk = Android.compileSdkVersion defaultConfig { minSdk = Android.minSdkVersion @@ -13,14 +15,9 @@ android { testInstrumentationRunner = Android.testInstrumentationRunner } - kotlinOptions { - jvmTarget = Versions.java - freeCompilerArgs += listOf( - "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", - "-Xuse-experimental=kotlinx.coroutines.FlowPreview", - "-opt-in=kotlin.ExperimentalUnsignedTypes", - "-opt-in=kotlin.RequiresOptIn" - ) + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) } java { @@ -29,17 +26,15 @@ android { } } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlin { - jvmToolchain(17) + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) } buildFeatures { - buildConfig = true compose = true } @@ -49,13 +44,23 @@ android { } dependencies { - implementation(project(":common:theme")) - implementation(project(":common:resources")) + implementation(project(":libs:datetime")) + implementation(project(":libs:logging")) + //Jetpack compose implementation(platform(Libs.compose_bom)) implementation(Libs.compose_ui) debugImplementation(Libs.compose_ui_tools) implementation(Libs.compose_ui_tools_preview) - implementation(Libs.compose_material) - implementation(Libs.compose_accompanist) + implementation(Libs.kotlinx_coroutines_core) + implementation(Libs.kotlinx_serialization_json) + implementation(Libs.inject) + + + androidTestImplementation(Libs.androidx_junit) + androidTestImplementation(Libs.junit) + androidTestImplementation(Libs.androidx_test_runner) + implementation(Libs.hilt) + implementation(Libs.timber) -} \ No newline at end of file + implementation(Libs.bugsnag) +} diff --git a/libs/network/connectivity/src/main/AndroidManifest.xml b/libs/network/connectivity/src/main/AndroidManifest.xml new file mode 100644 index 000000000..668f10c71 --- /dev/null +++ b/libs/network/connectivity/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/api/src/main/java/com/getcode/utils/network/Api24NetworkObserver.kt b/libs/network/connectivity/src/main/kotlin/com/getcode/utils/network/Api24NetworkObserver.kt similarity index 98% rename from api/src/main/java/com/getcode/utils/network/Api24NetworkObserver.kt rename to libs/network/connectivity/src/main/kotlin/com/getcode/utils/network/Api24NetworkObserver.kt index fa341facf..c406545b2 100644 --- a/api/src/main/java/com/getcode/utils/network/Api24NetworkObserver.kt +++ b/libs/network/connectivity/src/main/kotlin/com/getcode/utils/network/Api24NetworkObserver.kt @@ -5,10 +5,8 @@ import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import android.net.wifi.WifiManager -import android.os.Build import android.telephony.PhoneStateListener import android.telephony.TelephonyManager -import androidx.annotation.RequiresApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview diff --git a/api/src/main/java/com/getcode/utils/network/Api29NeworkObserver.kt b/libs/network/connectivity/src/main/kotlin/com/getcode/utils/network/Api29NeworkObserver.kt similarity index 100% rename from api/src/main/java/com/getcode/utils/network/Api29NeworkObserver.kt rename to libs/network/connectivity/src/main/kotlin/com/getcode/utils/network/Api29NeworkObserver.kt diff --git a/api/src/main/java/com/getcode/utils/network/NetworkConnectionListener.kt b/libs/network/connectivity/src/main/kotlin/com/getcode/utils/network/NetworkConnectionListener.kt similarity index 85% rename from api/src/main/java/com/getcode/utils/network/NetworkConnectionListener.kt rename to libs/network/connectivity/src/main/kotlin/com/getcode/utils/network/NetworkConnectionListener.kt index ea48596aa..ddbac9d12 100644 --- a/api/src/main/java/com/getcode/utils/network/NetworkConnectionListener.kt +++ b/libs/network/connectivity/src/main/kotlin/com/getcode/utils/network/NetworkConnectionListener.kt @@ -1,9 +1,13 @@ package com.getcode.utils.network +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +val LocalNetworkObserver: ProvidableCompositionLocal = staticCompositionLocalOf { NetworkObserverStub() } + interface NetworkConnectivityListener { val state: StateFlow val isConnected: Boolean diff --git a/api/src/main/java/com/getcode/utils/network/Retry.kt b/libs/network/connectivity/src/main/kotlin/com/getcode/utils/network/Retry.kt similarity index 100% rename from api/src/main/java/com/getcode/utils/network/Retry.kt rename to libs/network/connectivity/src/main/kotlin/com/getcode/utils/network/Retry.kt index e5c6128e0..d98dd8ad2 100644 --- a/api/src/main/java/com/getcode/utils/network/Retry.kt +++ b/libs/network/connectivity/src/main/kotlin/com/getcode/utils/network/Retry.kt @@ -8,7 +8,6 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource suspend fun retryable( - call: suspend () -> T, maxRetries: Int = 3, delayDuration: Duration = 2.seconds, onRetry: (Int) -> Unit = { currentAttempt -> @@ -26,6 +25,7 @@ suspend fun retryable( type = TraceType.Error ) }, + call: suspend () -> T, ): T? { var currentAttempt = 0 val startTime = TimeSource.Monotonic.markNow() diff --git a/libs/network/connectivity/src/main/kotlin/com/getcode/utils/network/connectivity/ConnectionStatusProvider.kt b/libs/network/connectivity/src/main/kotlin/com/getcode/utils/network/connectivity/ConnectionStatusProvider.kt new file mode 100644 index 000000000..3ccf9ec55 --- /dev/null +++ b/libs/network/connectivity/src/main/kotlin/com/getcode/utils/network/connectivity/ConnectionStatusProvider.kt @@ -0,0 +1,26 @@ +package com.getcode.utils.network.connectivity + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.getcode.utils.network.ConnectionType +import com.getcode.utils.network.NetworkState +import com.getcode.utils.network.SignalStrength + +class NetworkStateProvider ( + override val values: Sequence = sequenceOf( + NetworkState( + connected = false, + type = ConnectionType.Unknown, + signalStrength = SignalStrength.Unknown + ), + NetworkState( + connected = false, + type = ConnectionType.Wifi, + signalStrength = SignalStrength.Great + ), + NetworkState( + connected = true, + type = ConnectionType.Wifi, + signalStrength = SignalStrength.Great + ), + ) +): PreviewParameterProvider \ No newline at end of file diff --git a/libs/network/exchange/.gitignore b/libs/network/exchange/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/libs/network/exchange/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libs/network/exchange/build.gradle.kts b/libs/network/exchange/build.gradle.kts new file mode 100644 index 000000000..0253bd365 --- /dev/null +++ b/libs/network/exchange/build.gradle.kts @@ -0,0 +1,67 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.libs.exchange" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.compose_compiler + } +} + +dependencies { + implementation(project(":libs:currency")) + + //Jetpack compose + implementation(platform(Libs.compose_bom)) + implementation(Libs.compose_ui) + + + implementation(Libs.kotlinx_coroutines_core) + implementation(Libs.kotlinx_serialization_json) + implementation(Libs.kotlinx_datetime) + implementation(Libs.inject) + + + androidTestImplementation(Libs.androidx_junit) + androidTestImplementation(Libs.junit) + androidTestImplementation(Libs.androidx_test_runner) + implementation(Libs.hilt) + + implementation(Libs.timber) + implementation(Libs.bugsnag) +} diff --git a/libs/network/exchange/src/main/kotlin/com/getcode/network/exchange/Exchange.kt b/libs/network/exchange/src/main/kotlin/com/getcode/network/exchange/Exchange.kt new file mode 100644 index 000000000..9331fa80b --- /dev/null +++ b/libs/network/exchange/src/main/kotlin/com/getcode/network/exchange/Exchange.kt @@ -0,0 +1,63 @@ +package com.getcode.network.exchange + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import com.getcode.model.CurrencyCode +import com.getcode.model.Rate +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +val LocalExchange: ProvidableCompositionLocal = staticCompositionLocalOf { ExchangeNull() } + +interface Exchange { + val localRate: Rate + fun observeLocalRate(): Flow + + val entryRate: Rate + fun observeEntryRate(): Flow + + fun rates(): Map + fun observeRates(): Flow> + + suspend fun fetchRatesIfNeeded() + + fun rateFor(currencyCode: CurrencyCode): Rate? + + fun rateForUsd(): Rate? +} + +class ExchangeNull : Exchange { + override val localRate: Rate + get() = Rate.oneToOne + + override val entryRate: Rate + get() = Rate.oneToOne + + + override fun observeLocalRate(): Flow { + return emptyFlow() + } + + override fun observeEntryRate(): Flow { + return emptyFlow() + } + + override fun rates(): Map { + return emptyMap() + } + + override fun observeRates(): Flow> { + return emptyFlow() + } + + override suspend fun fetchRatesIfNeeded() = Unit + + override fun rateFor(currencyCode: CurrencyCode): Rate? { + return null + } + + override fun rateForUsd(): Rate? { + return null + } + +} \ No newline at end of file diff --git a/libs/opengraph/.gitignore b/libs/opengraph/.gitignore new file mode 100644 index 000000000..9f2a07880 --- /dev/null +++ b/libs/opengraph/.gitignore @@ -0,0 +1,2 @@ +build/ +.gradle/ diff --git a/libs/opengraph/build.gradle.kts b/libs/opengraph/build.gradle.kts new file mode 100644 index 000000000..a6f6cdd3a --- /dev/null +++ b/libs/opengraph/build.gradle.kts @@ -0,0 +1,64 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.libs.opengraph" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.compose_compiler + } +} + +dependencies { + //Jetpack compose + implementation(platform(Libs.compose_bom)) + implementation(Libs.compose_ui) + + implementation("org.jsoup:jsoup:1.16.1") + implementation(project(":libs:encryption:utils")) + + implementation(Libs.kotlinx_coroutines_core) + implementation(Libs.kotlinx_serialization_json) + implementation(Libs.inject) + + implementation(Libs.androidx_datastore) + + implementation(Libs.hilt) + + implementation(Libs.timber) + implementation(Libs.bugsnag) +} diff --git a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/OpenGraphCacheProvider.kt b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/OpenGraphCacheProvider.kt new file mode 100644 index 000000000..62f5f850f --- /dev/null +++ b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/OpenGraphCacheProvider.kt @@ -0,0 +1,56 @@ +package com.getcode.libs.opengraph + +import android.content.Context +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStoreFile +import com.getcode.libs.opengraph.cache.CacheProvider +import com.getcode.libs.opengraph.model.OpenGraphResult +import com.getcode.utils.base64 +import com.getcode.utils.decodeBase64 +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import javax.inject.Inject + +class OpenGraphCacheProvider @Inject constructor( + @ApplicationContext + context: Context +): CacheProvider { + + private val dataScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val storage = PreferenceDataStoreFactory.create( + corruptionHandler = ReplaceFileCorruptionHandler( + produceNewData = { emptyPreferences() } + ), + migrations = listOf(), + scope = dataScope, + produceFile = { context.preferencesDataStoreFile("open-graph") } + ) + + override suspend fun get(url: String): OpenGraphResult? { + return storage.data.map { prefs -> + val result = prefs[stringPreferencesKey(url)] ?: return@map null + Json.decodeFromString(result.decodeBase64().decodeToString()) + }.firstOrNull() + } + + override suspend fun set(openGraphResult: OpenGraphResult, url: String) { + dataScope.launch(Dispatchers.IO) { + storage.edit { prefs -> + prefs[stringPreferencesKey(url)] = Json.encodeToString(openGraphResult).encodeToByteArray().base64 + } + } + } +} \ No newline at end of file diff --git a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/OpenGraphParser.kt b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/OpenGraphParser.kt new file mode 100644 index 000000000..b36c2246f --- /dev/null +++ b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/OpenGraphParser.kt @@ -0,0 +1,89 @@ +package com.getcode.libs.opengraph + +import androidx.compose.runtime.staticCompositionLocalOf +import com.getcode.libs.opengraph.cache.CacheProvider +import com.getcode.libs.opengraph.callback.OpenGraphCallback +import com.getcode.libs.opengraph.fetcher.JsoupFetcher +import com.getcode.libs.opengraph.fetcher.checkNullParserResult +import com.getcode.libs.opengraph.model.OpenGraphResult +import com.getcode.libs.opengraph.model.Proxy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext + +val LocalOpenGraphParser = staticCompositionLocalOf { null } + +class OpenGraphParser @Inject constructor( + private val showNullOnEmpty: Boolean = false, + private val cacheProvider: CacheProvider? = null, + timeout: Int? = null, + proxy: Proxy? = null, + maxBodySize: Int? = null +) { + private val fetcher = JsoupFetcher(timeout, proxy, maxBodySize) + + fun parse(url: String, callback: OpenGraphCallback) { + ParseLink(url, callback).parse() + } + + inner class ParseLink(private val url: String, private val callback: OpenGraphCallback) : CoroutineScope { + private val job: Job = Job() + override val coroutineContext: CoroutineContext + get() = Dispatchers.Main + job + + fun parse() = launch { + val result = fetchContent(url, callback) + result?.let { + callback.onResponse(it) + } + } + } + private suspend fun fetchContent(url: String, callback: OpenGraphCallback) = withContext(Dispatchers.IO) { + var validatedUrl = url + if (!validatedUrl.contains("http")) { + validatedUrl = "http://$validatedUrl" + } + + cacheProvider?.get(url)?.let { + return@withContext it + } + + var openGraphResult: OpenGraphResult? = null + AGENTS.forEach { + openGraphResult = fetcher.call(validatedUrl, it) + val isResultNull = checkNullParserResult(openGraphResult) + if (!isResultNull) { + openGraphResult?.let { cacheProvider?.set(it, url) } + return@withContext openGraphResult + } + } + + if (checkNullParserResult(openGraphResult)) { + launch(Dispatchers.Main) { + callback.onError("Null or empty response from the server") + } + return@withContext null + } + + openGraphResult?.let { cacheProvider?.set(it, url) } + + return@withContext openGraphResult + } + + companion object { + private val AGENTS = arrayOf( + "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)", + "Mozilla", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36", + "WhatsApp/2.19.81 A", + "facebookexternalhit/1.1", + "facebookcatalog/1.0" + ) + } + +} \ No newline at end of file diff --git a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/cache/CacheProvider.kt b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/cache/CacheProvider.kt new file mode 100644 index 000000000..4ea4691fd --- /dev/null +++ b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/cache/CacheProvider.kt @@ -0,0 +1,8 @@ +package com.getcode.libs.opengraph.cache + +import com.getcode.libs.opengraph.model.OpenGraphResult + +interface CacheProvider { + suspend fun get(url: String): OpenGraphResult? + suspend fun set(openGraphResult: OpenGraphResult, url: String) +} \ No newline at end of file diff --git a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/callback/OpenGraphCallback.kt b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/callback/OpenGraphCallback.kt new file mode 100644 index 000000000..8cda6d74d --- /dev/null +++ b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/callback/OpenGraphCallback.kt @@ -0,0 +1,8 @@ +package com.getcode.libs.opengraph.callback + +import com.getcode.libs.opengraph.model.OpenGraphResult + +interface OpenGraphCallback { + fun onResponse(result: OpenGraphResult) + fun onError(error: String) +} \ No newline at end of file diff --git a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/fetcher/JsoupFetcher.kt b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/fetcher/JsoupFetcher.kt new file mode 100644 index 000000000..63a83dca3 --- /dev/null +++ b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/fetcher/JsoupFetcher.kt @@ -0,0 +1,111 @@ +package com.getcode.libs.opengraph.fetcher + +import com.getcode.libs.opengraph.model.OpenGraphResult +import com.getcode.libs.opengraph.model.Proxy +import org.jsoup.Jsoup +import java.net.URI +import java.net.URL + +class JsoupFetcher( + private val timeout: Int? = DEFAULT_TIMEOUT, + private val jsoupProxy: Proxy? = null, + private val maxBodySize: Int? = null +) { + fun call(url: String, agent: String): OpenGraphResult? { + var image: String? = null + var description: String? = null + var title: String? = null + var resultUrl: String? = null + var siteName: String? = null + var type: String? = null + + return try { + val connection = Jsoup.connect(url) + .ignoreContentType(true) + .userAgent(agent) + .referrer(REFERRER) + .timeout(timeout ?: DEFAULT_TIMEOUT) + .followRedirects(true) + + jsoupProxy?.let { connection.proxy(it.host, it.port) } + maxBodySize?.let { connection.maxBodySize(it) } + + val response = connection.execute() + val doc = response.parse() + val ogTags = doc.select(DOC_SELECT_OGTAGS) + + ogTags.forEach { tag -> + when (tag.attr(PROPERTY)) { + OG_IMAGE -> { + image = tag.attr(OPEN_GRAPH_KEY) + } + OG_DESCRIPTION -> { + description = tag.attr(OPEN_GRAPH_KEY) + } + + OG_URL -> { + resultUrl = tag.attr(OPEN_GRAPH_KEY) + } + + OG_TITLE -> { + title = tag.attr(OPEN_GRAPH_KEY) + } + + OG_SITE_NAME -> { + siteName = tag.attr(OPEN_GRAPH_KEY) + } + + OG_TYPE -> { + type = tag.attr(OPEN_GRAPH_KEY) + } + } + } + + if (title.isNullOrEmpty()) { + title = doc.title() + } + + if (description.isNullOrEmpty()) { + val docSelection = doc.select(DOC_SELECT_DESCRIPTION) + description = docSelection.firstOrNull()?.attr("content").orEmpty() + } + + if (resultUrl.isNullOrEmpty()) { + resultUrl = getBaseUrl(url) + } + + OpenGraphResult(title, description, resultUrl, image, siteName, type) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + companion object { + private const val REFERRER = "http://flipchat.xyz" + private const val DEFAULT_TIMEOUT = 60000 + + private const val DOC_SELECT_OGTAGS = "meta[property^=og:]" + private const val DOC_SELECT_DESCRIPTION = "meta[name=description]" + + private const val OPEN_GRAPH_KEY = "content" + private const val PROPERTY = "property" + + private const val OG_IMAGE = "og:image" + private const val OG_DESCRIPTION = "og:description" + private const val OG_URL = "og:url" + private const val OG_TITLE = "og:title" + private const val OG_SITE_NAME = "og:site_name" + private const val OG_TYPE = "og:type" + } +} + +fun checkNullParserResult(openGraphResult: OpenGraphResult?): Boolean { + return (openGraphResult?.title.isNullOrEmpty() || openGraphResult?.title == "null") && + (openGraphResult?.description.isNullOrEmpty() || openGraphResult?.description == "null") +} + +private fun getBaseUrl(urlString: String): String { + val url: URL = URI.create(urlString).toURL() + return url.protocol.toString() + "://" + url.authority + "/" +} diff --git a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/model/OpenGraphResult.kt b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/model/OpenGraphResult.kt new file mode 100644 index 000000000..f692a4eb1 --- /dev/null +++ b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/model/OpenGraphResult.kt @@ -0,0 +1,13 @@ +package com.getcode.libs.opengraph.model + +import kotlinx.serialization.Serializable + +@Serializable +data class OpenGraphResult( + val title: String? = null, + val description: String? = null, + val url: String? = null, + val image: String? = null, + val siteName: String? = null, + val type: String? = null +) diff --git a/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/model/Proxy.kt b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/model/Proxy.kt new file mode 100644 index 000000000..cacf99e61 --- /dev/null +++ b/libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/model/Proxy.kt @@ -0,0 +1,6 @@ +package com.getcode.libs.opengraph.model + +data class Proxy( + val host: String, + val port: Int, +) diff --git a/libs/permissions/.gitignore b/libs/permissions/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/libs/permissions/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libs/permissions/build.gradle.kts b/libs/permissions/build.gradle.kts new file mode 100644 index 000000000..7b329dca9 --- /dev/null +++ b/libs/permissions/build.gradle.kts @@ -0,0 +1,71 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.libs.permissions" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.compose_compiler + } +} + +dependencies { + implementation(project(":libs:logging")) + implementation(project(":libs:messaging")) + implementation(project(":ui:components")) + implementation(project(":ui:resources")) + + + //Jetpack compose + implementation(platform(Libs.compose_bom)) + implementation(Libs.compose_ui) + implementation(Libs.compose_activities) + + implementation(Libs.kotlinx_coroutines_core) + implementation(Libs.kotlinx_serialization_json) + implementation(Libs.kotlinx_datetime) + implementation(Libs.inject) + + + androidTestImplementation(Libs.androidx_junit) + androidTestImplementation(Libs.junit) + androidTestImplementation(Libs.androidx_test_runner) + implementation(Libs.hilt) + + implementation(Libs.timber) + implementation(Libs.bugsnag) +} diff --git a/libs/permissions/src/main/kotlin/com/getcode/util/ActivityLocator.kt b/libs/permissions/src/main/kotlin/com/getcode/util/ActivityLocator.kt new file mode 100644 index 000000000..ce8890cdb --- /dev/null +++ b/libs/permissions/src/main/kotlin/com/getcode/util/ActivityLocator.kt @@ -0,0 +1,13 @@ +package com.getcode.util + +import android.content.Context +import android.content.ContextWrapper +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity + +fun Context.getActivity(): FragmentActivity? = when (this) { + is AppCompatActivity -> this + is FragmentActivity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/login/CameraPermissionCheck.kt b/libs/permissions/src/main/kotlin/com/getcode/util/permissions/CameraPermissionCheck.kt similarity index 85% rename from app/src/main/java/com/getcode/view/login/CameraPermissionCheck.kt rename to libs/permissions/src/main/kotlin/com/getcode/util/permissions/CameraPermissionCheck.kt index 7fc1300c7..59703b148 100644 --- a/app/src/main/java/com/getcode/view/login/CameraPermissionCheck.kt +++ b/libs/permissions/src/main/kotlin/com/getcode/util/permissions/CameraPermissionCheck.kt @@ -1,4 +1,4 @@ -package com.getcode.view.login +package com.getcode.util.permissions import android.Manifest import androidx.compose.runtime.Composable @@ -7,16 +7,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext -import com.getcode.R +import com.getcode.libs.permissions.R import com.getcode.manager.TopBarManager -import com.getcode.ui.components.PermissionResult -import com.getcode.ui.components.getPermissionLauncher -import com.getcode.ui.components.rememberPermissionChecker -import com.getcode.util.launchAppSettings @Composable fun cameraPermissionCheck(isShowError: Boolean = true, onResult: (Boolean) -> Unit): (Boolean) -> Unit { - val permissionChecker = rememberPermissionChecker() + val permissionChecker = rememberPermissionHandler() val context = LocalContext.current var permissionRequested by remember { mutableStateOf(false) } val onPermissionError = { diff --git a/app/src/main/java/com/getcode/view/login/NotificationPermissionCheck.kt b/libs/permissions/src/main/kotlin/com/getcode/util/permissions/NotificationPermissionCheck.kt similarity index 85% rename from app/src/main/java/com/getcode/view/login/NotificationPermissionCheck.kt rename to libs/permissions/src/main/kotlin/com/getcode/util/permissions/NotificationPermissionCheck.kt index 389d87aeb..47462855b 100644 --- a/app/src/main/java/com/getcode/view/login/NotificationPermissionCheck.kt +++ b/libs/permissions/src/main/kotlin/com/getcode/util/permissions/NotificationPermissionCheck.kt @@ -1,7 +1,11 @@ -package com.getcode.view.login +package com.getcode.util.permissions import android.Manifest +import android.content.Context +import android.content.Intent +import android.net.Uri import android.os.Build +import android.provider.Settings import androidx.annotation.RequiresApi import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -9,12 +13,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext -import com.getcode.R +import androidx.core.content.ContextCompat +import com.getcode.libs.permissions.R import com.getcode.manager.TopBarManager -import com.getcode.ui.components.PermissionResult -import com.getcode.ui.components.getPermissionLauncher -import com.getcode.ui.components.rememberPermissionChecker -import com.getcode.util.launchAppSettings @Composable fun notificationPermissionCheck(isShowError: Boolean = true, onResult: (Boolean) -> Unit): (shouldRequest: Boolean) -> Unit { @@ -32,7 +33,7 @@ private fun notificationPermissionCheckApi33( onResult: (Boolean) -> Unit ): (shouldRequest: Boolean) -> Unit { val context = LocalContext.current - val permissionChecker = rememberPermissionChecker() + val permissionChecker = rememberPermissionHandler() var permissionRequested by remember { mutableStateOf(false) } val onPermissionError = { TopBarManager.showMessage( @@ -107,4 +108,14 @@ private fun notificationPermissionCheckApiLegacy( } return permissionCheck +} + +internal fun Context.appSettings() = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + flags = Intent.FLAG_ACTIVITY_NEW_TASK +} + +internal fun Context.launchAppSettings() { + val intent = appSettings() + ContextCompat.startActivity(this, intent, null) } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/PermissionCheck.kt b/libs/permissions/src/main/kotlin/com/getcode/util/permissions/PermissionCheck.kt similarity index 92% rename from app/src/main/java/com/getcode/ui/components/PermissionCheck.kt rename to libs/permissions/src/main/kotlin/com/getcode/util/permissions/PermissionCheck.kt index e5e5fa84d..ace76d72e 100644 --- a/app/src/main/java/com/getcode/ui/components/PermissionCheck.kt +++ b/libs/permissions/src/main/kotlin/com/getcode/util/permissions/PermissionCheck.kt @@ -1,4 +1,4 @@ -package com.getcode.ui.components +package com.getcode.util.permissions import android.app.Activity import android.content.Context @@ -11,7 +11,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat -import com.getcode.ui.utils.getActivity +import com.getcode.util.getActivity enum class PermissionResult { Granted, Denied, ShouldShowRationale @@ -49,14 +49,14 @@ fun getPermissionLauncher( } @Composable -fun rememberPermissionChecker(): PermissionChecker { +fun rememberPermissionHandler(): PermissionHandler{ val context = LocalContext.current return remember(context) { - PermissionChecker(context) + PermissionHandler(context) } } -class PermissionChecker(private val context: Context) { +class PermissionHandler(private val context: Context) { fun request( permission: String, shouldRequest: Boolean = true, diff --git a/app/src/main/java/com/getcode/util/permissions/PermissionChecker.kt b/libs/permissions/src/main/kotlin/com/getcode/util/permissions/PermissionChecker.kt similarity index 100% rename from app/src/main/java/com/getcode/util/permissions/PermissionChecker.kt rename to libs/permissions/src/main/kotlin/com/getcode/util/permissions/PermissionChecker.kt diff --git a/libs/quickresponse/.gitignore b/libs/quickresponse/.gitignore new file mode 100644 index 000000000..d16386367 --- /dev/null +++ b/libs/quickresponse/.gitignore @@ -0,0 +1 @@ +build/ \ No newline at end of file diff --git a/libs/quickresponse/build.gradle.kts b/libs/quickresponse/build.gradle.kts new file mode 100644 index 000000000..839d2c314 --- /dev/null +++ b/libs/quickresponse/build.gradle.kts @@ -0,0 +1,63 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.libs.qr" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.compose_compiler + } +} + +dependencies { + //Jetpack compose + implementation(platform(Libs.compose_bom)) + implementation(Libs.compose_ui) + + api(Libs.zxing) + + implementation(Libs.inject) + + + androidTestImplementation(Libs.androidx_junit) + androidTestImplementation(Libs.junit) + androidTestImplementation(Libs.androidx_test_runner) + implementation(Libs.hilt) + + implementation(Libs.timber) + implementation(Libs.bugsnag) +} diff --git a/app/src/main/java/com/getcode/util/QRCodeGenerator.kt b/libs/quickresponse/src/main/kotlin/com/getcode/libs/qr/QRCodeGenerator.kt similarity index 89% rename from app/src/main/java/com/getcode/util/QRCodeGenerator.kt rename to libs/quickresponse/src/main/kotlin/com/getcode/libs/qr/QRCodeGenerator.kt index 98e1cc9b7..3e3664bc7 100644 --- a/app/src/main/java/com/getcode/util/QRCodeGenerator.kt +++ b/libs/quickresponse/src/main/kotlin/com/getcode/libs/qr/QRCodeGenerator.kt @@ -1,4 +1,4 @@ -package com.getcode.util +package com.getcode.libs.qr import android.graphics.Bitmap import android.graphics.Color @@ -19,15 +19,18 @@ import com.google.zxing.WriterException import com.google.zxing.qrcode.QRCodeWriter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch - -fun generateQrCode(url: String, size: Int): Bitmap? { - return generateQr( - url = url, - size = size, - padding = 0, - contentColor = Color.WHITE, - spaceColor = Color.TRANSPARENT - ) +import javax.inject.Inject + +class QRCodeGenerator @Inject constructor() { + fun generate(url: String, size: Int): Bitmap? { + return generateQr( + url = url, + size = size, + padding = 0, + contentColor = Color.WHITE, + spaceColor = Color.TRANSPARENT + ) + } } @Composable diff --git a/libs/requests/.gitignore b/libs/requests/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/libs/requests/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libs/requests/build.gradle.kts b/libs/requests/build.gradle.kts new file mode 100644 index 000000000..97d67c2da --- /dev/null +++ b/libs/requests/build.gradle.kts @@ -0,0 +1,73 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.libs.requests" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.compose_compiler + } +} + +dependencies { + implementation(project(":services:shared")) + implementation(project(":libs:currency")) + implementation(project(":libs:logging")) + implementation(project(":libs:encryption:base58")) + implementation(project(":libs:encryption:keys")) + implementation(project(":libs:encryption:mnemonic")) + implementation(project(":libs:encryption:utils")) + implementation(project(":ui:resources")) + + //Jetpack compose + implementation(platform(Libs.compose_bom)) + implementation(Libs.compose_ui) + + implementation(Libs.kotlinx_coroutines_core) + implementation(Libs.kotlinx_serialization_json) + implementation(Libs.kotlinx_datetime) + implementation(Libs.inject) + + + androidTestImplementation(Libs.androidx_junit) + androidTestImplementation(Libs.junit) + androidTestImplementation(Libs.androidx_test_runner) + implementation(Libs.hilt) + + implementation(Libs.timber) + implementation(Libs.bugsnag) +} diff --git a/libs/requests/src/main/kotlin/com/getcode/domain/BillController.kt b/libs/requests/src/main/kotlin/com/getcode/domain/BillController.kt new file mode 100644 index 000000000..2d3816e26 --- /dev/null +++ b/libs/requests/src/main/kotlin/com/getcode/domain/BillController.kt @@ -0,0 +1,25 @@ +package com.getcode.domain + +import com.getcode.models.BillState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BillController @Inject constructor( + +) { + private val _state = MutableStateFlow(BillState.Default) + val state: StateFlow + get() = _state + + fun update(function: (BillState) -> BillState) { + _state.update(function) + } + + fun reset() { + _state.update { BillState.Default } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/models/BillState.kt b/libs/requests/src/main/kotlin/com/getcode/models/BillState.kt similarity index 83% rename from app/src/main/java/com/getcode/models/BillState.kt rename to libs/requests/src/main/kotlin/com/getcode/models/BillState.kt index 32620e5a1..7555a2e1d 100644 --- a/app/src/main/java/com/getcode/models/BillState.kt +++ b/libs/requests/src/main/kotlin/com/getcode/models/BillState.kt @@ -4,22 +4,26 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import com.getcode.R -import com.getcode.model.CodePayload -import com.getcode.model.SocialUser -import com.getcode.model.Domain +import com.getcode.extensions.formatted +import com.getcode.libs.requests.R +import com.getcode.model.ID import com.getcode.model.Kin import com.getcode.model.KinAmount import com.getcode.model.Rate +import com.getcode.model.SocialUser import com.getcode.model.TwitterUser -import com.getcode.util.formatted +import com.getcode.services.model.CodePayload +import com.getcode.services.model.ExtendedMetadata +import com.getcode.solana.keys.PublicKey data class BillState( val bill: Bill?, val showToast: Boolean, val toast: BillToast?, val valuation: Valuation?, - val paymentConfirmation: PaymentConfirmation?, + val privatePaymentConfirmation: PrivatePaymentConfirmation?, + val publicPaymentConfirmation: PublicPaymentConfirmation?, + val messageTipPaymentConfirmation: MessageTipPaymentConfirmation?, val loginConfirmation: LoginConfirmation?, val socialUserPaymentConfirmation: SocialUserPaymentConfirmation?, val primaryAction: Action?, @@ -37,7 +41,9 @@ data class BillState( showToast = false, toast = null, valuation = null, - paymentConfirmation = null, + privatePaymentConfirmation = null, + publicPaymentConfirmation = null, + messageTipPaymentConfirmation = null, loginConfirmation = null, socialUserPaymentConfirmation = null, primaryAction = null, @@ -193,21 +199,43 @@ data class BillToast( sealed class Confirmation( open val showScrim: Boolean = false, open val state: ConfirmationState, + open val cancellable: Boolean = false, ) -data class PaymentConfirmation( +data class PrivatePaymentConfirmation( override val state: ConfirmationState, val payload: CodePayload, val requestedAmount: KinAmount, val localAmount: KinAmount, override val showScrim: Boolean = false, + override val cancellable: Boolean = false, ): Confirmation(showScrim, state) data class LoginConfirmation( override val state: ConfirmationState, val payload: CodePayload, - val domain: Domain, + val domain: com.getcode.model.Domain, override val showScrim: Boolean = false, + override val cancellable: Boolean = false, +): Confirmation(showScrim, state) + + +data class PublicPaymentConfirmation( + override val state: ConfirmationState, + val amount: KinAmount, + val destination: PublicKey, + val metadata: ExtendedMetadata, + override val showScrim: Boolean = true, + override val cancellable: Boolean = true, +): Confirmation(showScrim, state) + +data class MessageTipPaymentConfirmation( + override val state: ConfirmationState, + val destination: PublicKey, + val metadata: ExtendedMetadata, + val balance: String?, + override val showScrim: Boolean = true, + override val cancellable: Boolean = true, ): Confirmation(showScrim, state) data class SocialUserPaymentConfirmation( @@ -217,6 +245,7 @@ data class SocialUserPaymentConfirmation( val metadata: SocialUser, val isPrivate: Boolean = false, override val showScrim: Boolean = false, + override val cancellable: Boolean = false, ): Confirmation(showScrim, state) { val imageUrl: String? get() { diff --git a/app/src/main/java/com/getcode/models/PaymentRequest.kt b/libs/requests/src/main/kotlin/com/getcode/models/PaymentRequest.kt similarity index 90% rename from app/src/main/java/com/getcode/models/PaymentRequest.kt rename to libs/requests/src/main/kotlin/com/getcode/models/PaymentRequest.kt index c80677696..1c2598821 100644 --- a/app/src/main/java/com/getcode/models/PaymentRequest.kt +++ b/libs/requests/src/main/kotlin/com/getcode/models/PaymentRequest.kt @@ -2,12 +2,8 @@ package com.getcode.models import android.net.Uri import com.getcode.model.CurrencyCode -import com.getcode.model.Domain -import com.getcode.model.Fee -import com.getcode.model.Fiat -import com.getcode.network.repository.decodeBase64 -import com.getcode.solana.keys.PublicKey import com.getcode.utils.ErrorUtils +import com.getcode.utils.decodeBase64 import com.getcode.vendor.Base58 import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -100,7 +96,7 @@ data class DeepLinkRequest( return null } - val destination = runCatching { PublicKey.fromBase58(destinationString) } + val destination = runCatching { com.getcode.solana.keys.PublicKey.fromBase58(destinationString) } .getOrNull() if (destination == null) { ErrorUtils.handleError(Throwable()) @@ -111,7 +107,7 @@ data class DeepLinkRequest( // optional fees val fees = container.decode>("fees").orEmpty() - val fiat = Fiat(currency = currencyCode, amount = amount) + val fiat = com.getcode.model.Fiat(currency = currencyCode, amount = amount) Timber.d("fiat=${fiat.amount}, fees=$fees") return baseRequest.copy( @@ -126,7 +122,7 @@ data class DeepLinkRequest( val loginContainer = container.decode("login") ?: return null - val verifier = runCatching { PublicKey.fromBase58(loginContainer.verifier.orEmpty()) } + val verifier = runCatching { com.getcode.solana.keys.PublicKey.fromBase58(loginContainer.verifier.orEmpty()) } .getOrNull() if (verifier == null) { @@ -135,7 +131,7 @@ data class DeepLinkRequest( return null } - val domain = Domain.from(loginContainer.domain) ?: return null + val domain = com.getcode.model.Domain.from(loginContainer.domain) ?: return null return baseRequest.copy( loginRequest = LoginRequest( @@ -160,14 +156,14 @@ data class DeepLinkRequest( } } data class PaymentRequest( - val fiat: Fiat, - val destination: PublicKey, + val fiat: com.getcode.model.Fiat, + val destination: com.getcode.solana.keys.PublicKey, val fees: List, ) data class LoginRequest( - val verifier: PublicKey, - val domain: Domain, + val verifier: com.getcode.solana.keys.PublicKey, + val domain: com.getcode.model.Domain, ) data class TipRequest( diff --git a/libs/vibrator/.gitignore b/libs/vibrator/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/libs/vibrator/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libs/vibrator/build.gradle.kts b/libs/vibrator/build.gradle.kts new file mode 100644 index 000000000..111a75dca --- /dev/null +++ b/libs/vibrator/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.util.vibration" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + compileOptions { + sourceCompatibility(Versions.java) + targetCompatibility(Versions.java) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.compose_compiler + } +} + +dependencies { + //Jetpack compose + implementation(platform(Libs.compose_bom)) + implementation(Libs.compose_ui) + + implementation(Libs.timber) +} diff --git a/libs/vibrator/src/main/AndroidManifest.xml b/libs/vibrator/src/main/AndroidManifest.xml new file mode 100644 index 000000000..933179036 --- /dev/null +++ b/libs/vibrator/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/getcode/util/AndroidVibrator.kt b/libs/vibrator/src/main/kotlin/com/getcode/util/vibration/AndroidVibrator.kt similarity index 95% rename from app/src/main/java/com/getcode/util/AndroidVibrator.kt rename to libs/vibrator/src/main/kotlin/com/getcode/util/vibration/AndroidVibrator.kt index 0e0e500a9..b040c434c 100644 --- a/app/src/main/java/com/getcode/util/AndroidVibrator.kt +++ b/libs/vibrator/src/main/kotlin/com/getcode/util/vibration/AndroidVibrator.kt @@ -1,4 +1,4 @@ -package com.getcode.util +package com.getcode.util.vibration import android.os.Build import android.os.CombinedVibration @@ -6,7 +6,6 @@ import android.os.VibrationEffect import android.os.VibratorManager import android.view.HapticFeedbackConstants import androidx.annotation.RequiresApi -import com.getcode.util.vibration.Vibrator class Api25Vibrator( private val vibrator: android.os.Vibrator diff --git a/app/src/main/java/com/getcode/util/vibration/Vibrator.kt b/libs/vibrator/src/main/kotlin/com/getcode/util/vibration/Vibrator.kt similarity index 100% rename from app/src/main/java/com/getcode/util/vibration/Vibrator.kt rename to libs/vibrator/src/main/kotlin/com/getcode/util/vibration/Vibrator.kt diff --git a/scripts/fetch-protos.sh b/scripts/fetch-protos.sh index 8c9b063ec..050f05f17 100755 --- a/scripts/fetch-protos.sh +++ b/scripts/fetch-protos.sh @@ -1,14 +1,14 @@ #!/bin/bash root=$(pwd) -REPO_URL="https://github.com/code-payments/code-protobuf-api" # Default repo URL +REPO_URL="git@github.com:code-payments/code-protobuf-api.git" # Default repo URL COMMIT_SHA="" RUN_STRIP_PROTO_VALIDATION=false # Default to not running the script TEMP_DIR=$(mktemp -d) -DEST_DIR="service/protos/src/main/proto" +TARGET="code" # Parse options -while getopts ":r:x" opt; do +while getopts ":r:t:x" opt; do case ${opt} in r ) REPO_URL=$OPTARG @@ -16,6 +16,12 @@ while getopts ":r:x" opt; do x ) RUN_STRIP_PROTO_VALIDATION=true ;; + t ) + TARGET=$OPTARG + if [ "$TARGET" == "flipchat" ]; then + REPO_URL="git@github.com:code-payments/flipchat-protobuf-api.git" + fi + ;; \? ) echo "Invalid option: -$OPTARG" >&2 exit 1 @@ -25,6 +31,8 @@ done shift $((OPTIND -1)) +DEST_DIR="definitions/$TARGET/protos/src/main/proto" + # Get the commit SHA if provided COMMIT_SHA=$1 diff --git a/scripts/internal-testing-build.sh b/scripts/internal-testing-build.sh index f370a0455..adba3ab82 100755 --- a/scripts/internal-testing-build.sh +++ b/scripts/internal-testing-build.sh @@ -6,10 +6,10 @@ export NOTIFY_ERRORS=true export DEBUG_MINIFY=true export DEBUG_CRASHLYTICS_UPLOAD=true -./gradlew assembleDebug +./gradlew :flipchatApp:assembleDebug -outputDir="$(pwd)/app/build/outputs/apk/debug" -mv "${outputDir}/app-debug.apk" "${outputDir}/app-${date}-debug.apk" +outputDir="$(pwd)/flipchatApp/build/outputs/apk/debug" +mv "${outputDir}/flipchatApp-debug.apk" "${outputDir}/flipchatApp-${date}-debug.apk" unset NOTIFY_ERRORS unset DEBUG_MINIFY diff --git a/scripts/strip-proto-validation.sh b/scripts/strip-proto-validation.sh index 10c99af5d..0f2ea76c6 100755 --- a/scripts/strip-proto-validation.sh +++ b/scripts/strip-proto-validation.sh @@ -4,13 +4,15 @@ root=$(pwd) +target=$1 + # 1. hack: first add a couple newlines after all "];" -find "${root}"/service/protos/src/main/proto -name "*.proto" -type f -exec sh -c "awk '{gsub(/];/, \"];\n\n\"); print}' {} > tmp && mv tmp {}" \; +find "${root}"/definitions/$target/protos/src/main/proto -name "*.proto" -type f -exec sh -c "awk '{gsub(/];/, \"];\n\n\"); print}' {} > tmp && mv tmp {}" \; # 2. strip everything between square brackets [...] ignoring lines starting with // -find "${root}"/service/protos/src/main/proto -name "*.proto" -type f -exec sh -c "awk '!/^[[:space:]]*\/\// {gsub(/ \[.*\]/, \"\");} {print}' {} > tmp && mv tmp {}" \; +find "${root}"/definitions/$target/protos/src/main/proto -name "*.proto" -type f -exec sh -c "awk '!/^[[:space:]]*\/\// {gsub(/ \[.*\]/, \"\");} {print}' {} > tmp && mv tmp {}" \; -find "${root}"/service/protos/src/main/proto -name "*.proto" -type f | while read -r file; do +find "${root}"/definitions/$target/protos/src/main/proto -name "*.proto" -type f | while read -r file; do awk ' BEGIN { in_repeated = 0; buffer = "" } { @@ -37,7 +39,7 @@ done #find "${root}"/service/protos/src/main/proto -name "*.proto" -type f -exec sh -c "awk '{gsub(/}/, \"}\n\"); print}' {} > tmp && mv tmp {}" \; # 4. strip validate import statement -find "${root}"/service/protos/src/main/proto -name "*.proto" -type f -exec sh -c "awk -v RS='' '{gsub(/import \"validate\/validate.proto\";/, \"\"); print}' {} > tmp && mv tmp {}" \; +find "${root}"/definitions/$target/protos/src/main/proto -name "*.proto" -type f -exec sh -c "awk -v RS='' '{gsub(/import \"validate\/validate.proto\";/, \"\"); print}' {} > tmp && mv tmp {}" \; # 5. add a newline after all trailing } brackets #find "${root}"/service/protos/src/main/proto -name "*.proto" -type f -exec sh -c "awk '{gsub(/}/, \"}\n\"); print}' {} > tmp && mv tmp {}" \; \ No newline at end of file diff --git a/services/code/.gitignore b/services/code/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/services/code/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/services/code/build.gradle.kts b/services/code/build.gradle.kts new file mode 100644 index 000000000..3871d0eda --- /dev/null +++ b/services/code/build.gradle.kts @@ -0,0 +1,126 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.services" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + + consumerProguardFiles("consumer-rules.pro") + + buildConfigField("String", "VERSION_NAME", "\"${Packaging.Code.versionName}\"") + buildConfigField("Boolean", "NOTIFY_ERRORS", "false") + buildConfigField( + "String", + "GOOGLE_CLOUD_PROJECT_NUMBER", + "\"${tryReadProperty(rootProject.rootDir, "GOOGLE_CLOUD_PROJECT_NUMBER", "-1L")}\"" + ) + + buildConfigField( + "String", + "FINGERPRINT_API_KEY", + "\"${tryReadProperty(rootProject.rootDir, "FINGERPRINT_API_KEY")}\"" + ) + + javaCompileOptions { + annotationProcessorOptions { + arguments += mapOf("room.schemaLocation" to "$projectDir/schemas") + } + } + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } + + buildFeatures { + buildConfig = true + } +} + +dependencies { + implementation(project(":definitions:code:models")) + api(project(":services:shared")) + + api(project(":libs:datetime")) + api(project(":libs:crypto:kin")) + api(project(":libs:crypto:solana")) + api(project(":libs:currency")) + api(project(":libs:encryption:base58")) + api(project(":libs:encryption:ed25519")) + api(project(":libs:encryption:hmac")) + api(project(":libs:encryption:keys")) + api(project(":libs:encryption:mnemonic")) + api(project(":libs:encryption:sha256")) + api(project(":libs:encryption:sha512")) + api(project(":libs:encryption:utils")) + api(project(":libs:logging")) + api(project(":libs:models")) + api(project(":libs:network:exchange")) + api(project(":libs:network:connectivity")) + + implementation(project(":libs:locale")) + implementation(project(":ui:resources")) + + implementation(Libs.rxjava) + implementation(Libs.kotlinx_coroutines_core) + implementation(Libs.kotlinx_serialization_json) + implementation(Libs.inject) + + implementation(Libs.grpc_android) + implementation(Libs.grpc_okhttp) + implementation(Libs.grpc_kotlin) + implementation(Libs.androidx_lifecycle_runtime) + implementation(Libs.androidx_room_runtime) + implementation(Libs.androidx_room_ktx) + implementation(Libs.androidx_room_rxjava3) + implementation(Libs.androidx_room_paging) + implementation(Libs.okhttp) + implementation(Libs.mixpanel) + + implementation(platform(Libs.firebase_bom)) + implementation(Libs.firebase_crashlytics) + implementation(Libs.firebase_installations) + implementation(Libs.firebase_perf) + implementation(Libs.firebase_messaging) + + implementation(Libs.play_integrity) + + implementation(Libs.androidx_paging_runtime) + + kapt(Libs.androidx_room_compiler) + implementation(Libs.sqlcipher) + + implementation(Libs.fingerprint_pro) + + implementation(Libs.lib_phone_number_google) + + androidTestImplementation(Libs.androidx_junit) + androidTestImplementation(Libs.junit) + androidTestImplementation(Libs.androidx_test_runner) + + implementation(Libs.hilt) + kapt(Libs.hilt_android_compiler) + kapt(Libs.hilt_compiler) + + implementation(Libs.timber) + implementation(Libs.bugsnag) +} diff --git a/services/code/consumer-rules.pro b/services/code/consumer-rules.pro new file mode 100644 index 000000000..5f642a47c --- /dev/null +++ b/services/code/consumer-rules.pro @@ -0,0 +1,6 @@ +# Needed to keep generic signatures +-keepattributes Signature + +-keepclasseswithmembernames class * { + native ; +} \ No newline at end of file diff --git a/api/schemas/com.getcode.db.AppDatabase/1.json b/services/code/schemas/com.getcode.db.CodeAppDatabase/1.json similarity index 100% rename from api/schemas/com.getcode.db.AppDatabase/1.json rename to services/code/schemas/com.getcode.db.CodeAppDatabase/1.json diff --git a/api/schemas/com.getcode.db.AppDatabase/10.json b/services/code/schemas/com.getcode.db.CodeAppDatabase/10.json similarity index 100% rename from api/schemas/com.getcode.db.AppDatabase/10.json rename to services/code/schemas/com.getcode.db.CodeAppDatabase/10.json diff --git a/api/schemas/com.getcode.db.AppDatabase/11.json b/services/code/schemas/com.getcode.db.CodeAppDatabase/11.json similarity index 100% rename from api/schemas/com.getcode.db.AppDatabase/11.json rename to services/code/schemas/com.getcode.db.CodeAppDatabase/11.json diff --git a/api/schemas/com.getcode.db.AppDatabase/12.json b/services/code/schemas/com.getcode.db.CodeAppDatabase/12.json similarity index 100% rename from api/schemas/com.getcode.db.AppDatabase/12.json rename to services/code/schemas/com.getcode.db.CodeAppDatabase/12.json diff --git a/api/schemas/com.getcode.db.AppDatabase/13.json b/services/code/schemas/com.getcode.db.CodeAppDatabase/13.json similarity index 100% rename from api/schemas/com.getcode.db.AppDatabase/13.json rename to services/code/schemas/com.getcode.db.CodeAppDatabase/13.json diff --git a/api/schemas/com.getcode.db.AppDatabase/14.json b/services/code/schemas/com.getcode.db.CodeAppDatabase/14.json similarity index 100% rename from api/schemas/com.getcode.db.AppDatabase/14.json rename to services/code/schemas/com.getcode.db.CodeAppDatabase/14.json diff --git a/api/schemas/com.getcode.db.AppDatabase/15.json b/services/code/schemas/com.getcode.db.CodeAppDatabase/15.json similarity index 100% rename from api/schemas/com.getcode.db.AppDatabase/15.json rename to services/code/schemas/com.getcode.db.CodeAppDatabase/15.json diff --git a/services/code/schemas/com.getcode.db.CodeAppDatabase/16.json b/services/code/schemas/com.getcode.db.CodeAppDatabase/16.json new file mode 100644 index 000000000..fbef9c245 --- /dev/null +++ b/services/code/schemas/com.getcode.db.CodeAppDatabase/16.json @@ -0,0 +1,246 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "74448cf3624e83c81490293ea858aaaf", + "entities": [ + { + "tableName": "CurrencyRate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `rate` REAL NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FaqItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT, `question` TEXT NOT NULL, `answer` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "question", + "columnName": "question", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "answer", + "columnName": "answer", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "GiftCard", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `entropy` TEXT NOT NULL, `amount` INTEGER NOT NULL, `date` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entropy", + "columnName": "entropy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exchangeData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fiat` REAL NOT NULL, `currency` TEXT NOT NULL, `synced_at` INTEGER NOT NULL, PRIMARY KEY(`currency`))", + "fields": [ + { + "fieldPath": "fx", + "columnName": "fiat", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "currency" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, '74448cf3624e83c81490293ea858aaaf')" + ] + } +} \ No newline at end of file diff --git a/api/schemas/com.getcode.db.AppDatabase/2.json b/services/code/schemas/com.getcode.db.CodeAppDatabase/2.json similarity index 100% rename from api/schemas/com.getcode.db.AppDatabase/2.json rename to services/code/schemas/com.getcode.db.CodeAppDatabase/2.json diff --git a/api/schemas/com.getcode.db.AppDatabase/3.json b/services/code/schemas/com.getcode.db.CodeAppDatabase/3.json similarity index 100% rename from api/schemas/com.getcode.db.AppDatabase/3.json rename to services/code/schemas/com.getcode.db.CodeAppDatabase/3.json diff --git a/api/schemas/com.getcode.db.AppDatabase/4.json b/services/code/schemas/com.getcode.db.CodeAppDatabase/4.json similarity index 100% rename from api/schemas/com.getcode.db.AppDatabase/4.json rename to services/code/schemas/com.getcode.db.CodeAppDatabase/4.json diff --git a/api/schemas/com.getcode.db.AppDatabase/5.json b/services/code/schemas/com.getcode.db.CodeAppDatabase/5.json similarity index 100% rename from api/schemas/com.getcode.db.AppDatabase/5.json rename to services/code/schemas/com.getcode.db.CodeAppDatabase/5.json diff --git a/api/schemas/com.getcode.db.AppDatabase/6.json b/services/code/schemas/com.getcode.db.CodeAppDatabase/6.json similarity index 100% rename from api/schemas/com.getcode.db.AppDatabase/6.json rename to services/code/schemas/com.getcode.db.CodeAppDatabase/6.json diff --git a/api/schemas/com.getcode.db.AppDatabase/7.json b/services/code/schemas/com.getcode.db.CodeAppDatabase/7.json similarity index 100% rename from api/schemas/com.getcode.db.AppDatabase/7.json rename to services/code/schemas/com.getcode.db.CodeAppDatabase/7.json diff --git a/api/schemas/com.getcode.db.AppDatabase/8.json b/services/code/schemas/com.getcode.db.CodeAppDatabase/8.json similarity index 100% rename from api/schemas/com.getcode.db.AppDatabase/8.json rename to services/code/schemas/com.getcode.db.CodeAppDatabase/8.json diff --git a/api/schemas/com.getcode.db.AppDatabase/9.json b/services/code/schemas/com.getcode.db.CodeAppDatabase/9.json similarity index 100% rename from api/schemas/com.getcode.db.AppDatabase/9.json rename to services/code/schemas/com.getcode.db.CodeAppDatabase/9.json diff --git a/api/schemas/com.kin.code.db.AppDatabase/1.json b/services/code/schemas/com.kin.code.db.AppDatabase/1.json similarity index 100% rename from api/schemas/com.kin.code.db.AppDatabase/1.json rename to services/code/schemas/com.kin.code.db.AppDatabase/1.json diff --git a/api/src/androidTest/java/com/getcode/codeScanner/KikEncodingTest.kt b/services/code/src/androidTest/java/com/getcode/codeScanner/KikEncodingTest.kt similarity index 94% rename from api/src/androidTest/java/com/getcode/codeScanner/KikEncodingTest.kt rename to services/code/src/androidTest/java/com/getcode/codeScanner/KikEncodingTest.kt index df48d407d..8a794fdca 100644 --- a/api/src/androidTest/java/com/getcode/codeScanner/KikEncodingTest.kt +++ b/services/code/src/androidTest/java/com/getcode/codeScanner/KikEncodingTest.kt @@ -1,6 +1,6 @@ package com.getcode.codeScanner -import com.getcode.network.repository.toByteList +import com.getcode.utils.toByteList import junit.framework.Assert.assertEquals import org.junit.Test diff --git a/api/src/androidTest/java/com/getcode/codeScanner/PayloadEncodingTest.kt b/services/code/src/androidTest/java/com/getcode/codeScanner/PayloadEncodingTest.kt similarity index 92% rename from api/src/androidTest/java/com/getcode/codeScanner/PayloadEncodingTest.kt rename to services/code/src/androidTest/java/com/getcode/codeScanner/PayloadEncodingTest.kt index ff740dc9a..495f0fdbf 100644 --- a/api/src/androidTest/java/com/getcode/codeScanner/PayloadEncodingTest.kt +++ b/services/code/src/androidTest/java/com/getcode/codeScanner/PayloadEncodingTest.kt @@ -1,8 +1,8 @@ package com.getcode.codeScanner -import com.getcode.model.CodePayload +import com.getcode.services.model.CodePayload import com.getcode.model.Kin -import com.getcode.model.Kind +import com.getcode.services.model.Kind import junit.framework.Assert.assertEquals import org.junit.Test diff --git a/api/src/androidTest/java/com/getcode/mocks/SolanaTransaction.kt b/services/code/src/androidTest/java/com/getcode/mocks/SolanaTransaction.kt similarity index 99% rename from api/src/androidTest/java/com/getcode/mocks/SolanaTransaction.kt rename to services/code/src/androidTest/java/com/getcode/mocks/SolanaTransaction.kt index aa6ea533d..6b6ea6c10 100644 --- a/api/src/androidTest/java/com/getcode/mocks/SolanaTransaction.kt +++ b/services/code/src/androidTest/java/com/getcode/mocks/SolanaTransaction.kt @@ -1,7 +1,7 @@ package com.getcode.mocks -import com.getcode.network.repository.decodeBase64 import com.getcode.solana.SolanaTransaction +import com.getcode.utils.decodeBase64 object SolanaTransaction { /// Mock Timelock Create Account Transaction diff --git a/api/src/androidTest/java/com/getcode/models/CodePayloadTests.kt b/services/code/src/androidTest/java/com/getcode/models/CodePayloadTests.kt similarity index 92% rename from api/src/androidTest/java/com/getcode/models/CodePayloadTests.kt rename to services/code/src/androidTest/java/com/getcode/models/CodePayloadTests.kt index 5258b3b38..63c0878be 100644 --- a/api/src/androidTest/java/com/getcode/models/CodePayloadTests.kt +++ b/services/code/src/androidTest/java/com/getcode/models/CodePayloadTests.kt @@ -1,8 +1,8 @@ package com.getcode.models -import com.getcode.model.CodePayload -import com.getcode.model.Kind -import com.getcode.model.Username +import com.getcode.services.model.CodePayload +import com.getcode.services.model.Kind +import com.getcode.services.model.payload.Username import junit.framework.Assert import org.junit.Test diff --git a/api/src/androidTest/java/com/getcode/models/KinTests.kt b/services/code/src/androidTest/java/com/getcode/models/KinTests.kt similarity index 100% rename from api/src/androidTest/java/com/getcode/models/KinTests.kt rename to services/code/src/androidTest/java/com/getcode/models/KinTests.kt diff --git a/api/src/androidTest/java/com/getcode/models/intents/IntentDepositTest.kt b/services/code/src/androidTest/java/com/getcode/models/intents/IntentDepositTest.kt similarity index 97% rename from api/src/androidTest/java/com/getcode/models/intents/IntentDepositTest.kt rename to services/code/src/androidTest/java/com/getcode/models/intents/IntentDepositTest.kt index 85387e241..2b3bb8bc2 100644 --- a/api/src/androidTest/java/com/getcode/models/intents/IntentDepositTest.kt +++ b/services/code/src/androidTest/java/com/getcode/models/intents/IntentDepositTest.kt @@ -6,12 +6,9 @@ import com.getcode.crypt.MnemonicPhrase import com.getcode.model.Kin import com.getcode.model.intents.IntentDeposit import com.getcode.model.intents.actions.ActionTransfer -import com.getcode.solana.keys.LENGTH_32 -import com.getcode.solana.keys.PublicKey import com.getcode.solana.organizer.AccountType import com.getcode.solana.organizer.Organizer import com.getcode.solana.organizer.SlotType -import org.junit.Assert import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Before @@ -47,7 +44,9 @@ class IntentDepositTest { ) - assertNotEquals(intent.id, PublicKey(ByteArray(LENGTH_32).toList())) + assertNotEquals(intent.id, + com.getcode.solana.keys.PublicKey(ByteArray(com.getcode.solana.keys.LENGTH_32).toList()) + ) val resultTray = intent.resultTray diff --git a/api/src/androidTest/java/com/getcode/models/intents/IntentPrivateTransferTest.kt b/services/code/src/androidTest/java/com/getcode/models/intents/IntentPrivateTransferTest.kt similarity index 98% rename from api/src/androidTest/java/com/getcode/models/intents/IntentPrivateTransferTest.kt rename to services/code/src/androidTest/java/com/getcode/models/intents/IntentPrivateTransferTest.kt index 3c265961b..091c834fe 100644 --- a/api/src/androidTest/java/com/getcode/models/intents/IntentPrivateTransferTest.kt +++ b/services/code/src/androidTest/java/com/getcode/models/intents/IntentPrivateTransferTest.kt @@ -6,11 +6,13 @@ import com.getcode.crypt.MnemonicPhrase import com.getcode.model.CurrencyCode import com.getcode.model.Kin import com.getcode.model.KinAmount +import com.getcode.model.fromFiatAmount +import com.getcode.model.generate import com.getcode.model.intents.IntentPrivateTransfer import com.getcode.model.intents.actions.ActionOpenAccount import com.getcode.model.intents.actions.ActionTransfer import com.getcode.model.intents.actions.ActionWithdraw -import com.getcode.network.repository.toPublicKey +import com.getcode.model.toPublicKey import com.getcode.solana.keys.Key32.Companion.mock import com.getcode.solana.keys.PublicKey import com.getcode.solana.organizer.AccountType @@ -59,7 +61,7 @@ class IntentPrivateTransferTest { isWithdrawal = false, fee = Kin.fromKin(0), additionalFees = emptyList(), - tipMetadata = null, + metadata = null, ) assertEquals(rendezvous, intent.id) diff --git a/api/src/androidTest/java/com/getcode/models/intents/IntentPublicTransferTest.kt b/services/code/src/androidTest/java/com/getcode/models/intents/IntentPublicTransferTest.kt similarity index 93% rename from api/src/androidTest/java/com/getcode/models/intents/IntentPublicTransferTest.kt rename to services/code/src/androidTest/java/com/getcode/models/intents/IntentPublicTransferTest.kt index 05f056f6d..12015975f 100644 --- a/api/src/androidTest/java/com/getcode/models/intents/IntentPublicTransferTest.kt +++ b/services/code/src/androidTest/java/com/getcode/models/intents/IntentPublicTransferTest.kt @@ -6,12 +6,9 @@ import com.getcode.crypt.MnemonicPhrase import com.getcode.model.CurrencyCode import com.getcode.model.Kin import com.getcode.model.KinAmount -import com.getcode.model.intents.IntentPrivateTransfer +import com.getcode.model.fromFiatAmount import com.getcode.model.intents.IntentPublicTransfer -import com.getcode.model.intents.actions.ActionOpenAccount import com.getcode.model.intents.actions.ActionTransfer -import com.getcode.model.intents.actions.ActionWithdraw -import com.getcode.network.repository.toPublicKey import com.getcode.solana.keys.Key32.Companion.mock import com.getcode.solana.keys.PublicKey import com.getcode.solana.organizer.AccountType diff --git a/api/src/androidTest/java/com/getcode/models/intents/IntentReceiveTest.kt b/services/code/src/androidTest/java/com/getcode/models/intents/IntentReceiveTest.kt similarity index 97% rename from api/src/androidTest/java/com/getcode/models/intents/IntentReceiveTest.kt rename to services/code/src/androidTest/java/com/getcode/models/intents/IntentReceiveTest.kt index afe15f2ed..08d82609b 100644 --- a/api/src/androidTest/java/com/getcode/models/intents/IntentReceiveTest.kt +++ b/services/code/src/androidTest/java/com/getcode/models/intents/IntentReceiveTest.kt @@ -9,9 +9,7 @@ import com.getcode.model.intents.actions.ActionCloseEmptyAccount import com.getcode.model.intents.actions.ActionOpenAccount import com.getcode.model.intents.actions.ActionTransfer import com.getcode.model.intents.actions.ActionWithdraw -import com.getcode.network.repository.toPublicKey -import com.getcode.solana.keys.LENGTH_32 -import com.getcode.solana.keys.PublicKey +import com.getcode.model.toPublicKey import com.getcode.solana.organizer.AccountType import com.getcode.solana.organizer.Organizer import com.getcode.solana.organizer.SlotType @@ -51,7 +49,9 @@ class IntentReceiveTest { amount = amount ) - Assert.assertNotEquals(intent.id, PublicKey(ByteArray(LENGTH_32).toList())) + Assert.assertNotEquals(intent.id, + com.getcode.solana.keys.PublicKey(ByteArray(com.getcode.solana.keys.LENGTH_32).toList()) + ) val resultTray = intent.resultTray diff --git a/api/src/androidTest/java/com/getcode/models/intents/actions/ActionTest.kt b/services/code/src/androidTest/java/com/getcode/models/intents/actions/ActionTest.kt similarity index 81% rename from api/src/androidTest/java/com/getcode/models/intents/actions/ActionTest.kt rename to services/code/src/androidTest/java/com/getcode/models/intents/actions/ActionTest.kt index 47f5c0c69..1a669df10 100644 --- a/api/src/androidTest/java/com/getcode/models/intents/actions/ActionTest.kt +++ b/services/code/src/androidTest/java/com/getcode/models/intents/actions/ActionTest.kt @@ -2,18 +2,15 @@ package com.getcode.models.intents.actions import android.content.Context import androidx.test.platform.app.InstrumentationRegistry -import com.getcode.crypt.MnemonicPhrase -import com.getcode.solana.keys.Hash import com.getcode.model.Kin import com.getcode.model.intents.ServerParameter import com.getcode.model.intents.actions.* -import com.getcode.network.repository.decodeBase58 -import com.getcode.network.repository.toPublicKey +import com.getcode.model.toPublicKey import com.getcode.solana.keys.Key32.Companion.mock -import com.getcode.solana.keys.PublicKey import com.getcode.solana.organizer.AccountType import com.getcode.solana.organizer.Organizer import com.getcode.solana.organizer.SlotType +import com.getcode.utils.decodeBase58 import org.junit.Assert.* import org.junit.Before import org.junit.Test @@ -22,16 +19,16 @@ class ActionTest { lateinit var context: Context lateinit var organizer: Organizer - private val mnemonic = MnemonicPhrase.newInstance( + private val mnemonic = com.getcode.crypt.MnemonicPhrase.newInstance( words = "couple divorce usage surprise before range feature source bubble chunk spot away".split( " " ) )!! - private val nonce = PublicKey.fromBase58(base58 = "JDwJWHij1E75GVAAcMUPkwDgC598wRdF4a7d76QX895S") - private val blockhash = PublicKey.fromBase58(base58 = "BXLEqnSJxMHvEJQHRMSbsFQGDpBn891BpQo828xejbi1") - private val treasury = PublicKey.fromBase58(base58 = "Ddk7k7zMMWsp8fZB12wqbiADdXKQFWfwUUsxSo73JaQ9") - private val recentRoot = PublicKey.fromBase58(base58 = "2sDAFcEZkLd3mbm6SaZhifctkyB4NWsp94GMnfDs1BfR") + private val nonce = com.getcode.solana.keys.PublicKey.fromBase58(base58 = "JDwJWHij1E75GVAAcMUPkwDgC598wRdF4a7d76QX895S") + private val blockhash = com.getcode.solana.keys.PublicKey.fromBase58(base58 = "BXLEqnSJxMHvEJQHRMSbsFQGDpBn891BpQo828xejbi1") + private val treasury = com.getcode.solana.keys.PublicKey.fromBase58(base58 = "Ddk7k7zMMWsp8fZB12wqbiADdXKQFWfwUUsxSo73JaQ9") + private val recentRoot = com.getcode.solana.keys.PublicKey.fromBase58(base58 = "2sDAFcEZkLd3mbm6SaZhifctkyB4NWsp94GMnfDs1BfR") @Before fun setup() { @@ -191,8 +188,8 @@ class ActionTest { parameter = null, configs = listOf( ServerParameter.Config( - nonce = PublicKey.fromBase58(base58 = "JDwJWHij1E75GVAAcMUPkwDgC598wRdF4a7d76QX895S"), - blockhash = PublicKey.fromBase58(base58 = "BXLEqnSJxMHvEJQHRMSbsFQGDpBn891BpQo828xejbi1") + nonce = com.getcode.solana.keys.PublicKey.fromBase58(base58 = "JDwJWHij1E75GVAAcMUPkwDgC598wRdF4a7d76QX895S"), + blockhash = com.getcode.solana.keys.PublicKey.fromBase58(base58 = "BXLEqnSJxMHvEJQHRMSbsFQGDpBn891BpQo828xejbi1") ) ) ) @@ -200,19 +197,23 @@ class ActionTest { private val tempPrivacy: ServerParameter = ServerParameter( actionId = 0, parameter = ServerParameter.Parameter.TempPrivacy( - treasury = PublicKey.fromBase58(base58 = "Ddk7k7zMMWsp8fZB12wqbiADdXKQFWfwUUsxSo73JaQ9"), - recentRoot = PublicKey.fromBase58(base58 = "2sDAFcEZkLd3mbm6SaZhifctkyB4NWsp94GMnfDs1BfR") + treasury = com.getcode.solana.keys.PublicKey.fromBase58(base58 = "Ddk7k7zMMWsp8fZB12wqbiADdXKQFWfwUUsxSo73JaQ9"), + recentRoot = com.getcode.solana.keys.PublicKey.fromBase58(base58 = "2sDAFcEZkLd3mbm6SaZhifctkyB4NWsp94GMnfDs1BfR") ), configs = listOf( ServerParameter.Config( - nonce = PublicKey.fromBase58(base58 = "JDwJWHij1E75GVAAcMUPkwDgC598wRdF4a7d76QX895S"), - blockhash = PublicKey.fromBase58(base58 = "BXLEqnSJxMHvEJQHRMSbsFQGDpBn891BpQo828xejbi1") + nonce = com.getcode.solana.keys.PublicKey.fromBase58(base58 = "JDwJWHij1E75GVAAcMUPkwDgC598wRdF4a7d76QX895S"), + blockhash = com.getcode.solana.keys.PublicKey.fromBase58(base58 = "BXLEqnSJxMHvEJQHRMSbsFQGDpBn891BpQo828xejbi1") ) ) ) - private val leaf = PublicKey("2ocuvgy8ETZp9WDaEy4rpYz2QyeZ7JAiEvXKbW5rKcd4".decodeBase58().toList()) - private val root = Hash("9EuLAJgnMpEq8wmQUFTNxgYJG2FkAPAGCUhrNK447Uox".decodeBase58().toList()) + private val leaf = com.getcode.solana.keys.PublicKey( + "2ocuvgy8ETZp9WDaEy4rpYz2QyeZ7JAiEvXKbW5rKcd4".decodeBase58().toList() + ) + private val root = com.getcode.solana.keys.Hash( + "9EuLAJgnMpEq8wmQUFTNxgYJG2FkAPAGCUhrNK447Uox".decodeBase58().toList() + ) private val proof = listOf( "4DEt3CHLarXBy74hiJf5t74HmKfTw5DeLK2nzTLFv3Pq", "73uNXKLpHkTgc9ubvyRXTGaNUh19TUx8M9bN4PNTn544", @@ -277,7 +278,7 @@ class ActionTest { "3fCTfZwwMipFUpvbXbcDBBtwuo23hmuTJMYaVECWwNTP", "CZ94cA7JHBb4a8mqN9xEJquPNX1TxKqL3cBQms6yfgTr", "8PxPosHkG5Q6VBnhiimJaH88yPYt6ZDp4szMBfaVTLun", - ).map { PublicKey(it.decodeBase58().toList()) } + ).map { com.getcode.solana.keys.PublicKey(it.decodeBase58().toList()) } val privacyUpgrade = ServerParameter( actionId = 0, @@ -291,8 +292,12 @@ class ActionTest { ), configs = listOf( ServerParameter.Config( - nonce = PublicKey("JDwJWHij1E75GVAAcMUPkwDgC598wRdF4a7d76QX895S".decodeBase58().toList()), - blockhash = PublicKey("BXLEqnSJxMHvEJQHRMSbsFQGDpBn891BpQo828xejbi1".decodeBase58().toList()) + nonce = com.getcode.solana.keys.PublicKey( + "JDwJWHij1E75GVAAcMUPkwDgC598wRdF4a7d76QX895S".decodeBase58().toList() + ), + blockhash = com.getcode.solana.keys.PublicKey( + "BXLEqnSJxMHvEJQHRMSbsFQGDpBn891BpQo828xejbi1".decodeBase58().toList() + ) ) ) ) diff --git a/api/src/androidTest/java/com/getcode/solana/builder/TransactionBuilder_Test.kt b/services/code/src/androidTest/java/com/getcode/solana/builder/TransactionBuilder_Test.kt similarity index 53% rename from api/src/androidTest/java/com/getcode/solana/builder/TransactionBuilder_Test.kt rename to services/code/src/androidTest/java/com/getcode/solana/builder/TransactionBuilder_Test.kt index aa35c92e7..20b13360e 100644 --- a/api/src/androidTest/java/com/getcode/solana/builder/TransactionBuilder_Test.kt +++ b/services/code/src/androidTest/java/com/getcode/solana/builder/TransactionBuilder_Test.kt @@ -2,11 +2,9 @@ package com.getcode.solana.builder import com.getcode.mocks.SolanaTransaction import com.getcode.model.Kin +import com.getcode.model.extensions.newInstance import com.getcode.model.intents.actions.ActionType.Companion.kreIndex -import com.getcode.network.repository.decodeBase58 -import com.getcode.solana.keys.PublicKey -import com.getcode.solana.keys.Signature -import com.getcode.solana.keys.TimelockDerivedAccounts +import com.getcode.utils.decodeBase58 import junit.framework.Assert import org.junit.Test @@ -17,15 +15,23 @@ class TransactionBuilder_Test { val (transaction, _) = SolanaTransaction.mockCloseDormantAccount() val authority = - PublicKey("Ed3GWPEdMiRXDMf7jU46fRwBF7n6ZZFGN3vH1dYAgME2".decodeBase58().toList()) + com.getcode.solana.keys.PublicKey( + "Ed3GWPEdMiRXDMf7jU46fRwBF7n6ZZFGN3vH1dYAgME2".decodeBase58().toList() + ) val destination = - PublicKey("GEaVZeZ52Jn8xHPy4VKaXsHQ34E6pwfJGuYh8EsYQi6M".decodeBase58().toList()) + com.getcode.solana.keys.PublicKey( + "GEaVZeZ52Jn8xHPy4VKaXsHQ34E6pwfJGuYh8EsYQi6M".decodeBase58().toList() + ) val nonce = - PublicKey("27aoaJKNVtqKXRKQeMdKrtPMqAzcyYH5PGEgQ8x88TMH".decodeBase58().toList()) + com.getcode.solana.keys.PublicKey( + "27aoaJKNVtqKXRKQeMdKrtPMqAzcyYH5PGEgQ8x88TMH".decodeBase58().toList() + ) val blockhash = - PublicKey("7mezFVdzzwHfAxXCDo1gSdRTZE8WwQP9sHbAnPjS3AJD".decodeBase58().toList()) + com.getcode.solana.keys.PublicKey( + "7mezFVdzzwHfAxXCDo1gSdRTZE8WwQP9sHbAnPjS3AJD".decodeBase58().toList() + ) - val derivedAccounts = TimelockDerivedAccounts.newInstance(authority) + val derivedAccounts = com.getcode.solana.keys.TimelockDerivedAccounts.newInstance(authority) val builtTransaction = TransactionBuilder.closeDormantAccount( authority = authority, @@ -40,7 +46,7 @@ class TransactionBuilder_Test { val transactionNoSignatures = com.getcode.solana.SolanaTransaction( transaction.message, - listOf(Signature.zero, Signature.zero) + listOf(com.getcode.solana.keys.Signature.zero, com.getcode.solana.keys.Signature.zero) ) Assert.assertEquals(transactionNoSignatures.encode(), builtTransaction.encode()) @@ -51,15 +57,23 @@ class TransactionBuilder_Test { val (transaction, _) = SolanaTransaction.mockPrivateTransfer() val authority = - PublicKey("Ddk7k7zMMWsp8fZB12wqbiADdXKQFWfwUUsxSo73JaQ9".decodeBase58().toList()) + com.getcode.solana.keys.PublicKey( + "Ddk7k7zMMWsp8fZB12wqbiADdXKQFWfwUUsxSo73JaQ9".decodeBase58().toList() + ) val destination = - PublicKey("2sDAFcEZkLd3mbm6SaZhifctkyB4NWsp94GMnfDs1BfR".decodeBase58().toList()) + com.getcode.solana.keys.PublicKey( + "2sDAFcEZkLd3mbm6SaZhifctkyB4NWsp94GMnfDs1BfR".decodeBase58().toList() + ) val nonce = - PublicKey("H7y8REaqickypzCfke3onJVKbbp8ELmaccFYeLZzJ2Wn".decodeBase58().toList()) + com.getcode.solana.keys.PublicKey( + "H7y8REaqickypzCfke3onJVKbbp8ELmaccFYeLZzJ2Wn".decodeBase58().toList() + ) val blockhash = - PublicKey("HjD8boPVb9pBVMQBdSzUMTt1HKTonwPsC3RibtXw44pK".decodeBase58().toList()) + com.getcode.solana.keys.PublicKey( + "HjD8boPVb9pBVMQBdSzUMTt1HKTonwPsC3RibtXw44pK".decodeBase58().toList() + ) - val derivedAccounts = TimelockDerivedAccounts.newInstance(owner = authority) + val derivedAccounts = com.getcode.solana.keys.TimelockDerivedAccounts.newInstance(owner = authority) val builtTransaction = TransactionBuilder.transfer( timelockDerivedAccounts = derivedAccounts, @@ -74,7 +88,7 @@ class TransactionBuilder_Test { val transactionNoSignatures = com.getcode.solana.SolanaTransaction( transaction.message, - listOf(Signature.zero, Signature.zero) + listOf(com.getcode.solana.keys.Signature.zero, com.getcode.solana.keys.Signature.zero) ) Assert.assertEquals(transactionNoSignatures.encode(), builtTransaction.encode()) diff --git a/api/src/androidTest/java/com/getcode/solana/encoding/AgoraMemo_Test.kt b/services/code/src/androidTest/java/com/getcode/solana/encoding/AgoraMemo_Test.kt similarity index 100% rename from api/src/androidTest/java/com/getcode/solana/encoding/AgoraMemo_Test.kt rename to services/code/src/androidTest/java/com/getcode/solana/encoding/AgoraMemo_Test.kt diff --git a/api/src/androidTest/java/com/getcode/solana/encoding/MessageEncoding_Test.kt b/services/code/src/androidTest/java/com/getcode/solana/encoding/MessageEncoding_Test.kt similarity index 86% rename from api/src/androidTest/java/com/getcode/solana/encoding/MessageEncoding_Test.kt rename to services/code/src/androidTest/java/com/getcode/solana/encoding/MessageEncoding_Test.kt index 7ea803bda..ed7a6d651 100644 --- a/api/src/androidTest/java/com/getcode/solana/encoding/MessageEncoding_Test.kt +++ b/services/code/src/androidTest/java/com/getcode/solana/encoding/MessageEncoding_Test.kt @@ -1,11 +1,8 @@ package com.getcode.solana.encoding -import com.getcode.solana.keys.Hash -import com.getcode.solana.keys.PublicKey -import com.getcode.network.repository.toByteList -import com.getcode.solana.AccountMeta import com.getcode.solana.Message import com.getcode.solana.MessageHeader +import com.getcode.utils.toByteList import junit.framework.Assert.assertEquals import org.junit.Test @@ -16,7 +13,7 @@ class MessageEncoding_Test { MessageHeader(3, 1, 4) ) val hashes = listOf( - Hash( + com.getcode.solana.keys.Hash( listOf( 101, 116, 70, 63, 191, 201, 150, 31, 115, 132, 250, 155, 18, 171, 135, 213, 132, 223, 128, 23, 142, 119, 126, 47, 204, 233, 8, 136, 95, 8, 43, 153 @@ -26,8 +23,8 @@ class MessageEncoding_Test { val accounts = listOf( listOf( - AccountMeta.payer( - PublicKey( + com.getcode.solana.keys.AccountMeta.payer( + com.getcode.solana.keys.PublicKey( listOf( 9, 44, 14, 22, 184, 170, 97, 239, 12, 185, 70, 65, 119, 118, 114, 54, 255, 60, 52, 123, 82, 133, 164, 46, 40, 205, 154, 124, 39, 59, @@ -35,8 +32,8 @@ class MessageEncoding_Test { ).toByteList() ) ), - AccountMeta.writable( - PublicKey( + com.getcode.solana.keys.AccountMeta.writable( + com.getcode.solana.keys.PublicKey( listOf( 11, 188, 11, 147, 54, 247, 148, 189, 25, 169, 205, 216, 208, 118, 234, 39, 168, 247, 199, 188, 172, 150, 159, 153, 178, 201, 247, 3, @@ -45,8 +42,8 @@ class MessageEncoding_Test { ), signer = true ), - AccountMeta.readonly( - PublicKey( + com.getcode.solana.keys.AccountMeta.readonly( + com.getcode.solana.keys.PublicKey( listOf( 57, 88, 174, 113, 57, 253, 24, 181, 197, 67, 143, 220, 53, 132, 20, 11, 230, 5, 94, 238, 153, 242, 28, 230, 123, 135, 54, 69, 200, 188, @@ -55,8 +52,8 @@ class MessageEncoding_Test { ), signer = true ), - AccountMeta.writable( - PublicKey( + com.getcode.solana.keys.AccountMeta.writable( + com.getcode.solana.keys.PublicKey( listOf( 188, 72, 197, 118, 240, 244, 192, 109, 13, 22, 88, 163, 208, 174, 63, 174, 11, 201, 26, 143, 48, 48, 193, 21, 163, 215, 115, 44, 201, @@ -65,8 +62,8 @@ class MessageEncoding_Test { ), signer = false ), - AccountMeta.writable( - PublicKey( + com.getcode.solana.keys.AccountMeta.writable( + com.getcode.solana.keys.PublicKey( listOf( 249, 177, 29, 97, 248, 176, 59, 124, 27, 171, 138, 129, 191, 135, 7, 35, 191, 253, 252, 164, 96, 202, 198, 204, 68, 206, 100, 147, 68, @@ -75,8 +72,8 @@ class MessageEncoding_Test { ), signer = false ), - AccountMeta.readonly( - PublicKey( + com.getcode.solana.keys.AccountMeta.readonly( + com.getcode.solana.keys.PublicKey( listOf( 6, 167, 213, 23, 25, 44, 86, 142, 224, 138, 132, 95, 115, 210, 151, 136, 207, 3, 92, 49, 69, 178, 26, 179, 68, 216, 6, 46, 169, 64, 0, 0 @@ -84,8 +81,8 @@ class MessageEncoding_Test { ), signer = false ), - AccountMeta.readonly( - PublicKey( + com.getcode.solana.keys.AccountMeta.readonly( + com.getcode.solana.keys.PublicKey( listOf( 5, 74, 83, 80, 248, 93, 200, 130, 214, 20, 165, 86, 114, 120, 138, 41, 109, 223, 30, 171, 171, 208, 166, 6, 120, 136, 73, 50, 244, 238, @@ -94,8 +91,8 @@ class MessageEncoding_Test { ), signer = false ), - AccountMeta.readonly( - PublicKey( + com.getcode.solana.keys.AccountMeta.readonly( + com.getcode.solana.keys.PublicKey( listOf( 6, 221, 246, 225, 215, 101, 161, 147, 217, 203, 225, 70, 206, 235, 121, 172, 28, 180, 133, 237, 95, 91, 55, 145, 58, 140, 245, 133, @@ -104,8 +101,8 @@ class MessageEncoding_Test { ), signer = false ), - AccountMeta.readonly( - PublicKey( + com.getcode.solana.keys.AccountMeta.readonly( + com.getcode.solana.keys.PublicKey( listOf( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 diff --git a/api/src/androidTest/java/com/getcode/solana/encoding/Message_Test.kt b/services/code/src/androidTest/java/com/getcode/solana/encoding/Message_Test.kt similarity index 90% rename from api/src/androidTest/java/com/getcode/solana/encoding/Message_Test.kt rename to services/code/src/androidTest/java/com/getcode/solana/encoding/Message_Test.kt index d011720c0..07f5bb5dc 100644 --- a/api/src/androidTest/java/com/getcode/solana/encoding/Message_Test.kt +++ b/services/code/src/androidTest/java/com/getcode/solana/encoding/Message_Test.kt @@ -2,12 +2,10 @@ package com.getcode.solana.encoding import com.getcode.ed25519.Ed25519 -import com.getcode.solana.keys.Hash -import com.getcode.solana.AccountMeta +import com.getcode.solana.keys.AccountMeta import com.getcode.solana.Instruction import com.getcode.solana.Message import com.getcode.solana.MessageHeader -import com.getcode.solana.keys.PublicKey import junit.framework.Assert import org.junit.Test @@ -29,7 +27,8 @@ class Message_Test { @Test fun testMessageEncodeDecodeCycle() { - fun randomPublicKey() = PublicKey(Ed25519.createKeyPair().publicKeyBytes.toList()) + fun randomPublicKey() = + com.getcode.solana.keys.PublicKey(Ed25519.createKeyPair().publicKeyBytes.toList()) val program = randomPublicKey() val program2 = randomPublicKey() @@ -60,7 +59,7 @@ class Message_Test { ), ) - val blockhash = Hash(randomPublicKey().bytes) + val blockhash = com.getcode.solana.keys.Hash(randomPublicKey().bytes) val allAccounts = mutableListOf( AccountMeta.readonly(program), diff --git a/api/src/androidTest/java/com/getcode/solana/encoding/ShortVec_Test.kt b/services/code/src/androidTest/java/com/getcode/solana/encoding/ShortVec_Test.kt similarity index 100% rename from api/src/androidTest/java/com/getcode/solana/encoding/ShortVec_Test.kt rename to services/code/src/androidTest/java/com/getcode/solana/encoding/ShortVec_Test.kt diff --git a/api/src/androidTest/java/com/getcode/solana/encoding/ShortVec_Test2.kt b/services/code/src/androidTest/java/com/getcode/solana/encoding/ShortVec_Test2.kt similarity index 98% rename from api/src/androidTest/java/com/getcode/solana/encoding/ShortVec_Test2.kt rename to services/code/src/androidTest/java/com/getcode/solana/encoding/ShortVec_Test2.kt index 5fd24d905..0c4c315ce 100644 --- a/api/src/androidTest/java/com/getcode/solana/encoding/ShortVec_Test2.kt +++ b/services/code/src/androidTest/java/com/getcode/solana/encoding/ShortVec_Test2.kt @@ -1,7 +1,7 @@ package com.getcode.solana.encoding -import com.getcode.network.repository.toByteList import com.getcode.solana.ShortVec +import com.getcode.utils.toByteList import junit.framework.Assert.assertEquals import org.junit.Test diff --git a/api/src/androidTest/java/com/getcode/solana/instructions/programs/SwapValidatorTests.kt b/services/code/src/androidTest/java/com/getcode/solana/instructions/programs/SwapValidatorTests.kt similarity index 100% rename from api/src/androidTest/java/com/getcode/solana/instructions/programs/SwapValidatorTests.kt rename to services/code/src/androidTest/java/com/getcode/solana/instructions/programs/SwapValidatorTests.kt diff --git a/api/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_BurnDustWithAuthority_Test.kt b/services/code/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_BurnDustWithAuthority_Test.kt similarity index 100% rename from api/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_BurnDustWithAuthority_Test.kt rename to services/code/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_BurnDustWithAuthority_Test.kt diff --git a/api/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_CloseAccounts_Test.kt b/services/code/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_CloseAccounts_Test.kt similarity index 100% rename from api/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_CloseAccounts_Test.kt rename to services/code/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_CloseAccounts_Test.kt diff --git a/api/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_DeactivateLock_Test.kt b/services/code/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_DeactivateLock_Test.kt similarity index 100% rename from api/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_DeactivateLock_Test.kt rename to services/code/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_DeactivateLock_Test.kt diff --git a/api/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_Initialize_Test.kt b/services/code/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_Initialize_Test.kt similarity index 95% rename from api/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_Initialize_Test.kt rename to services/code/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_Initialize_Test.kt index 98786ed8a..6c628bdc6 100644 --- a/api/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_Initialize_Test.kt +++ b/services/code/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_Initialize_Test.kt @@ -2,7 +2,6 @@ package com.getcode.solana.instructions.programs import com.getcode.mocks.SolanaTransaction import com.getcode.solana.keys.base58 -import com.getcode.utils.DataSlice.byteToUnsignedInt import junit.framework.Assert.assertEquals import org.junit.Test diff --git a/api/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_RevokeLockWithAuthority_Test.kt b/services/code/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_RevokeLockWithAuthority_Test.kt similarity index 100% rename from api/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_RevokeLockWithAuthority_Test.kt rename to services/code/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_RevokeLockWithAuthority_Test.kt diff --git a/api/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_TransferWithAuthority_Test.kt b/services/code/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_TransferWithAuthority_Test.kt similarity index 100% rename from api/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_TransferWithAuthority_Test.kt rename to services/code/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_TransferWithAuthority_Test.kt diff --git a/api/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_Withdraw_Test.kt b/services/code/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_Withdraw_Test.kt similarity index 100% rename from api/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_Withdraw_Test.kt rename to services/code/src/androidTest/java/com/getcode/solana/instructions/programs/TimelockProgram_Withdraw_Test.kt diff --git a/api/src/androidTest/java/com/getcode/solana/keys/MerkleProofTest.kt b/services/code/src/androidTest/java/com/getcode/solana/keys/MerkleProofTest.kt similarity index 96% rename from api/src/androidTest/java/com/getcode/solana/keys/MerkleProofTest.kt rename to services/code/src/androidTest/java/com/getcode/solana/keys/MerkleProofTest.kt index bf6d16c68..b851b1612 100644 --- a/api/src/androidTest/java/com/getcode/solana/keys/MerkleProofTest.kt +++ b/services/code/src/androidTest/java/com/getcode/solana/keys/MerkleProofTest.kt @@ -1,6 +1,6 @@ package com.getcode.solana.keys -import com.getcode.network.repository.decodeBase58 +import com.getcode.utils.decodeBase58 import org.junit.Test import org.kin.sdk.base.models.toUTF8Bytes @@ -15,7 +15,7 @@ fun String.decodeHex(): ByteArray { } class MerkleProofServerTest { - private val root = Hash( + private val root = com.getcode.solana.keys.Hash( "1d92df473ed3fd6326f7ee570ec34547a42a487a7500366ee8ce3bd2e3f5c99c".decodeHex() .toList() ) @@ -38,7 +38,7 @@ class MerkleProofServerTest { "18ea423c80045847f939c0e57c6d6255d4cc7ed4c72f2c5528cc122fac687733", "cd097bb2b70eabc6538d44d1583c0f2712b5a6ff16d3d7f9c22455cf0d786f47", "be2ff6be7e99eca6736741b87cb131950f14496bd4eb8061a17a95f45b6fd9e8", - ).map { Hash(it.decodeHex().toList()) } + ).map { com.getcode.solana.keys.Hash(it.decodeHex().toList()) } @Test @@ -62,7 +62,7 @@ class MerkleProofServerTest { val nodeBytes = modifiedProof[index].bytes.toMutableList() nodeBytes[31] = 0xFF.toByte() // Modify the last byte of every node - modifiedProof[index] = Hash(nodeBytes) + modifiedProof[index] = com.getcode.solana.keys.Hash(nodeBytes) assert(!originalCommitment.verifyContained(root, modifiedProof)) } @@ -73,7 +73,7 @@ class MerkleProofClientTests { private val leaf = PublicKey( "2ocuvgy8ETZp9WDaEy4rpYz2QyeZ7JAiEvXKbW5rKcd4".decodeBase58().toList() ) - private val root = Hash( + private val root = com.getcode.solana.keys.Hash( "9EuLAJgnMpEq8wmQUFTNxgYJG2FkAPAGCUhrNK447Uox".decodeBase58().toList() ) diff --git a/api/src/androidTest/java/com/getcode/solana/keys/ProgramDerivedAccountTest.kt b/services/code/src/androidTest/java/com/getcode/solana/keys/ProgramDerivedAccountTest.kt similarity index 87% rename from api/src/androidTest/java/com/getcode/solana/keys/ProgramDerivedAccountTest.kt rename to services/code/src/androidTest/java/com/getcode/solana/keys/ProgramDerivedAccountTest.kt index a78353fd3..52d41c620 100644 --- a/api/src/androidTest/java/com/getcode/solana/keys/ProgramDerivedAccountTest.kt +++ b/services/code/src/androidTest/java/com/getcode/solana/keys/ProgramDerivedAccountTest.kt @@ -1,7 +1,8 @@ package com.getcode.solana.keys import com.getcode.model.Kin -import com.getcode.network.repository.decodeBase58 +import com.getcode.model.extensions.newInstance +import com.getcode.utils.decodeBase58 import org.junit.Assert.assertEquals import org.junit.Test @@ -34,8 +35,12 @@ class ProgramDerivedAccountTest { fun testCommitmentDerivation() { val treasury = PublicKey.fromBase58("3HR2k4etyHtBgHCAisRQ5mAU1x3GxWSgmm1bHsNzvZKS") val destination = PublicKey.fromBase58("A1WsiTaL6fPei2xcqDPiVnRDvRwpCjne3votXZmrQe86") - val recentRoot = Hash("BvtnzMe2CSunpGoYnvK6YZut1Jg41yaPBDGdJToPQrqy".decodeBase58().toList()) - val transcript = Hash("91aPsVLa6xCcVfC9FozexaMK8TgKCUZMkj4k6yPy2q4S".decodeBase58().toList()) + val recentRoot = com.getcode.solana.keys.Hash( + "BvtnzMe2CSunpGoYnvK6YZut1Jg41yaPBDGdJToPQrqy".decodeBase58().toList() + ) + val transcript = com.getcode.solana.keys.Hash( + "91aPsVLa6xCcVfC9FozexaMK8TgKCUZMkj4k6yPy2q4S".decodeBase58().toList() + ) val derivedAccounts = SplitterCommitmentAccounts.newInstance( treasury = treasury, @@ -61,7 +66,7 @@ class ProgramDerivedAccountTest { val transcript = SplitterTranscript( intentId = PublicKey.fromBase58(base58 = "4roBdWPCqbuqr4YtPavfi7hTAMdH52RXMDgKhqQ4qvX6"), actionId = 1, - amount = Kin.Companion.fromKin(40), + amount = Kin.fromKin(40), source = PublicKey.fromBase58(base58 = "GNVyMgwkFQvm3YLuJdEVW4xEoqDYnixVaxVYT59frGWW"), destination = PublicKey.fromBase58(base58 = "Cia66LdCtvfJ6G5jjmLtNoFx5JvWr3uNv2iaFvmSS9gW"), ) diff --git a/api/src/androidTest/java/com/getcode/solana/keys/SodiumTests.kt b/services/code/src/androidTest/java/com/getcode/solana/keys/SodiumTests.kt similarity index 60% rename from api/src/androidTest/java/com/getcode/solana/keys/SodiumTests.kt rename to services/code/src/androidTest/java/com/getcode/solana/keys/SodiumTests.kt index 450d047b1..9e0623807 100644 --- a/api/src/androidTest/java/com/getcode/solana/keys/SodiumTests.kt +++ b/services/code/src/androidTest/java/com/getcode/solana/keys/SodiumTests.kt @@ -1,9 +1,14 @@ package com.getcode.solana.keys import com.getcode.ed25519.Ed25519 -import com.getcode.network.repository.publicKeyFromBytes +import com.getcode.model.boxOpen +import com.getcode.model.boxSeal +import com.getcode.model.curvePrivate +import com.getcode.model.curvePublic +import com.getcode.model.encryptionPrivateKey +import com.getcode.model.shared +import com.getcode.model.toPublicKey import com.getcode.util.testBlocking -import com.getcode.vendor.Base58 import com.ionspin.kotlin.crypto.LibsodiumInitializer import org.junit.Assert import org.junit.Test @@ -15,7 +20,7 @@ class SodiumTests { testBlocking { LibsodiumInitializer.initialize() val privateKey = - PrivateKey(base58 = "4vXZTu7W8FKV2cNB7t2MTp8KXrWpJRCodzUPoyPy1MWZiZQqVVXUrycCdoagzPN6YE9w9pyTbZVzVw9iLDUT7adR") + com.getcode.solana.keys.PrivateKey(base58 = "4vXZTu7W8FKV2cNB7t2MTp8KXrWpJRCodzUPoyPy1MWZiZQqVVXUrycCdoagzPN6YE9w9pyTbZVzVw9iLDUT7adR") Assert.assertEquals( "F197LA9gxNFgu6bwmHFuBJWU4yuA3wRsBDky9twjeoJr", @@ -28,7 +33,8 @@ class SodiumTests { fun testPublicToCurve() { testBlocking { LibsodiumInitializer.initialize() - val publicKey = PublicKey(base58 = "GV6Aow3jPRXFQiC36EGc1BabhFVY1mEwKPEuwZorGh3R") + val publicKey = + PublicKey(base58 = "GV6Aow3jPRXFQiC36EGc1BabhFVY1mEwKPEuwZorGh3R") Assert.assertEquals( "37asXhXd7c8vUNCxHHxAMMrAGPCpYrAtJ8L1fvu4rxzU", @@ -42,12 +48,14 @@ class SodiumTests { testBlocking { LibsodiumInitializer.initialize() val privateKey1 = - PrivateKey(base58 = "2fJLfaTREkNBiDbB26dL4syDozhCEf2pNMorXvBf7593yC59d1kDFsXAA9cN63Bb5MDUgSeU5AhsfS2aTZQHoNyU") + com.getcode.solana.keys.PrivateKey(base58 = "2fJLfaTREkNBiDbB26dL4syDozhCEf2pNMorXvBf7593yC59d1kDFsXAA9cN63Bb5MDUgSeU5AhsfS2aTZQHoNyU") val privateKey2 = - PrivateKey(base58 = "3GKRCGo814rSVa6XkFARZGq13Rb7DSGwF2c6SSRSzMfyQ3wuDAPoELzhsvH6r5A1PFACpFuesDaRHUEoL1PFAxRa") + com.getcode.solana.keys.PrivateKey(base58 = "3GKRCGo814rSVa6XkFARZGq13Rb7DSGwF2c6SSRSzMfyQ3wuDAPoELzhsvH6r5A1PFACpFuesDaRHUEoL1PFAxRa") - val publicKey1 = PublicKey(base58 = "eMTkrsg1acVKyk8jp4b6JQM3TK2fSxwaZV3gZqCmxsp") - val publicKey2 = PublicKey(base58 = "J1uvrtrg42Yw3zA7v7VK1wBahW8XkTLxqsnKksZab9wS") + val publicKey1 = + PublicKey(base58 = "eMTkrsg1acVKyk8jp4b6JQM3TK2fSxwaZV3gZqCmxsp") + val publicKey2 = + PublicKey(base58 = "J1uvrtrg42Yw3zA7v7VK1wBahW8XkTLxqsnKksZab9wS") val privateCurve1 = privateKey1.curvePrivate.getOrNull()!! val privateCurve2 = privateKey2.curvePrivate.getOrNull()!! @@ -76,16 +84,18 @@ class SodiumTests { testBlocking { LibsodiumInitializer.initialize() val senderPrivate = - PrivateKey(base58 = "2tKSW5f1dag1pGzDSsM9yo32KSMNcTkBAvXEfZ1u2pcqkmo8oYcbtsnA8m9YVd8EUzVJeU5mvjFKjPQF2m4Xifg8") - val senderPublic = PublicKey(base58 = "3hpSY5ibVa87dDLJhLdVAy7QVso2Edhr28ZEJmpDF7UQ") + com.getcode.solana.keys.PrivateKey(base58 = "2tKSW5f1dag1pGzDSsM9yo32KSMNcTkBAvXEfZ1u2pcqkmo8oYcbtsnA8m9YVd8EUzVJeU5mvjFKjPQF2m4Xifg8") + val senderPublic = + PublicKey(base58 = "3hpSY5ibVa87dDLJhLdVAy7QVso2Edhr28ZEJmpDF7UQ") val receiverPrivate = - PrivateKey(base58 = "38EyWg6Eay5bhcZR465FD2agT2bf7BhyWNJJ64ypfdQGTb6mHU3an2f8pvWapSrE3j3hEFu1h7HYoa6eykAHUBJr") - val receiverPublic = PublicKey(base58 = "6Hsb5k8UjjsowqXgRBr1BR3EKFPeYjA8Nn9prYDU24v6") + com.getcode.solana.keys.PrivateKey(base58 = "38EyWg6Eay5bhcZR465FD2agT2bf7BhyWNJJ64ypfdQGTb6mHU3an2f8pvWapSrE3j3hEFu1h7HYoa6eykAHUBJr") + val receiverPublic = + PublicKey(base58 = "6Hsb5k8UjjsowqXgRBr1BR3EKFPeYjA8Nn9prYDU24v6") - val nonce = Base58.decode("Jc1X8GdaMmcRDRKiAaMZSRBDLZAFuf9xq").toList() + val nonce = com.getcode.vendor.Base58.decode("Jc1X8GdaMmcRDRKiAaMZSRBDLZAFuf9xq").toList() val expectedEncrypted = - Base58.decode("2eXsYDo1gcuYc1Nw7uUGZmJZrj2vu33TnrXve62HwzhyTggjjz").toList() + com.getcode.vendor.Base58.decode("2eXsYDo1gcuYc1Nw7uUGZmJZrj2vu33TnrXve62HwzhyTggjjz").toList() val message = "super secret message" @@ -117,12 +127,12 @@ class SodiumTests { fun testRoundTrip2() { testBlocking { LibsodiumInitializer.initialize() - val sender = Ed25519.createKeyPair(Base58.decode("BAjtXtzJzjMvF1qHicCQdyi4AC2y9tQMjVCSwNAY5jnz")) - val receiver = Ed25519.createKeyPair(Base58.decode("BWUXLs1epmgQwc6kf3VuWcX4bkwjiRjGDp3CYNcVDpVd")) + val sender = Ed25519.createKeyPair(com.getcode.vendor.Base58.decode("BAjtXtzJzjMvF1qHicCQdyi4AC2y9tQMjVCSwNAY5jnz")) + val receiver = Ed25519.createKeyPair(com.getcode.vendor.Base58.decode("BWUXLs1epmgQwc6kf3VuWcX4bkwjiRjGDp3CYNcVDpVd")) - val nonce = Base58.decode("Jc1X8GdaMmcRDRKiAaMZSRBDLZAFuf9xq").toList() + val nonce = com.getcode.vendor.Base58.decode("Jc1X8GdaMmcRDRKiAaMZSRBDLZAFuf9xq").toList() val expectedEncrypted = - Base58.decode("SZa3RhUVBNhuCT8ARoG5k7V7Ji6TtoJfX8JtpZEHyUzMe4EEb").toList() + com.getcode.vendor.Base58.decode("SZa3RhUVBNhuCT8ARoG5k7V7Ji6TtoJfX8JtpZEHyUzMe4EEb").toList() val message = "super secret message" @@ -154,13 +164,14 @@ class SodiumTests { fun testDecryptRealBlockchainMessage() { testBlocking { LibsodiumInitializer.initialize() - val senderPublic = PublicKey(base58 = "McS32C1q6Rv1odkEoR5g1xtFBN7TdbkLFvGeyvQtzLF") + val senderPublic = + PublicKey(base58 = "McS32C1q6Rv1odkEoR5g1xtFBN7TdbkLFvGeyvQtzLF") val receiverKeyPair = - Ed25519.createKeyPair(Base58.decode("CADTR1JPf4KzQ9fuYJMRaaWbfshB8qSb38RpFzC8mtjq")) + Ed25519.createKeyPair(com.getcode.vendor.Base58.decode("CADTR1JPf4KzQ9fuYJMRaaWbfshB8qSb38RpFzC8mtjq")) - val nonce = Base58.decode("PjgJtLTPZmHGCqJ6Sj1X4ZN8wVbinW4nU").toList() + val nonce = com.getcode.vendor.Base58.decode("PjgJtLTPZmHGCqJ6Sj1X4ZN8wVbinW4nU").toList() val encrypted = - Base58.decode("2BRs8n3fqqDUXVjEdup3d5zoxFALbvs6KcKnMCgpoJ6iafXjikwqbjnbehyha") + com.getcode.vendor.Base58.decode("2BRs8n3fqqDUXVjEdup3d5zoxFALbvs6KcKnMCgpoJ6iafXjikwqbjnbehyha") .toList() val expectedDecrypted = "Blockchain messaging is 🔥" @@ -179,4 +190,7 @@ class SodiumTests { ) } } -} \ No newline at end of file +} + +val Ed25519.KeyPair.publicKeyFromBytes: PublicKey + get() = publicKeyBytes.toPublicKey() \ No newline at end of file diff --git a/api/src/androidTest/java/com/getcode/solana/organizer/OrganizerTest.kt b/services/code/src/androidTest/java/com/getcode/solana/organizer/OrganizerTest.kt similarity index 98% rename from api/src/androidTest/java/com/getcode/solana/organizer/OrganizerTest.kt rename to services/code/src/androidTest/java/com/getcode/solana/organizer/OrganizerTest.kt index 5ec3eb6c3..a645a34dd 100644 --- a/api/src/androidTest/java/com/getcode/solana/organizer/OrganizerTest.kt +++ b/services/code/src/androidTest/java/com/getcode/solana/organizer/OrganizerTest.kt @@ -9,7 +9,8 @@ import com.getcode.crypt.MnemonicCache import com.getcode.crypt.MnemonicPhrase import com.getcode.model.AccountInfo import com.getcode.model.Kin -import com.getcode.network.repository.toPublicKey +import com.getcode.model.extensions.newInstance +import com.getcode.model.toPublicKey import com.getcode.solana.keys.* import org.junit.Assert.* import org.junit.Before diff --git a/api/src/androidTest/java/com/getcode/solana/organizer/SlotTest.kt b/services/code/src/androidTest/java/com/getcode/solana/organizer/SlotTest.kt similarity index 97% rename from api/src/androidTest/java/com/getcode/solana/organizer/SlotTest.kt rename to services/code/src/androidTest/java/com/getcode/solana/organizer/SlotTest.kt index 4e5996a8d..1014d9556 100644 --- a/api/src/androidTest/java/com/getcode/solana/organizer/SlotTest.kt +++ b/services/code/src/androidTest/java/com/getcode/solana/organizer/SlotTest.kt @@ -24,7 +24,7 @@ class SlotTest { @Test fun testBillCount() { - val tray = Tray.newInstance(context, mnemonic) + val tray = Tray.newInstance(mnemonic) tray.setBalances( mapOf( diff --git a/api/src/androidTest/java/com/getcode/solana/organizer/TrayTest.kt b/services/code/src/androidTest/java/com/getcode/solana/organizer/TrayTest.kt similarity index 100% rename from api/src/androidTest/java/com/getcode/solana/organizer/TrayTest.kt rename to services/code/src/androidTest/java/com/getcode/solana/organizer/TrayTest.kt diff --git a/api/src/androidTest/java/com/getcode/util/TestUtils.kt b/services/code/src/androidTest/java/com/getcode/util/TestUtils.kt similarity index 100% rename from api/src/androidTest/java/com/getcode/util/TestUtils.kt rename to services/code/src/androidTest/java/com/getcode/util/TestUtils.kt diff --git a/api/src/androidTest/java/com/getcode/util/UUIDTests.kt b/services/code/src/androidTest/java/com/getcode/util/UUIDTests.kt similarity index 100% rename from api/src/androidTest/java/com/getcode/util/UUIDTests.kt rename to services/code/src/androidTest/java/com/getcode/util/UUIDTests.kt diff --git a/services/code/src/main/java/com/getcode/CodeServicesConfig.kt b/services/code/src/main/java/com/getcode/CodeServicesConfig.kt new file mode 100644 index 000000000..86f9bfc94 --- /dev/null +++ b/services/code/src/main/java/com/getcode/CodeServicesConfig.kt @@ -0,0 +1,11 @@ +package com.getcode + +import com.getcode.services.BuildConfig +import com.getcode.services.ChannelConfig +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +internal data class CodeServicesConfig( + override val baseUrl: String = "api.codeinfra.net", + override val userAgent: String = "Code/Android/${BuildConfig.VERSION_NAME}", +): ChannelConfig diff --git a/api/src/main/java/com/getcode/analytics/AnalyticsManager.kt b/services/code/src/main/java/com/getcode/analytics/AnalyticsManager.kt similarity index 94% rename from api/src/main/java/com/getcode/analytics/AnalyticsManager.kt rename to services/code/src/main/java/com/getcode/analytics/AnalyticsManager.kt index 8bad1a1c5..50a7f7006 100644 --- a/api/src/main/java/com/getcode/analytics/AnalyticsManager.kt +++ b/services/code/src/main/java/com/getcode/analytics/AnalyticsManager.kt @@ -1,11 +1,10 @@ package com.getcode.analytics -import com.getcode.api.BuildConfig -import com.getcode.model.AppSetting -import com.getcode.model.CurrencyCode +import com.getcode.services.BuildConfig +import com.getcode.services.model.AppSetting import com.getcode.model.Kin import com.getcode.model.KinAmount -import com.getcode.model.PrefsBool +import com.getcode.services.model.PrefsBool import com.getcode.solana.keys.PublicKey import com.getcode.solana.keys.base58 import com.google.firebase.ktx.Firebase @@ -86,7 +85,7 @@ class AnalyticsManager @Inject constructor( override fun billTimeoutReached( kin: Kin, - currencyCode: CurrencyCode, + currencyCode: com.getcode.model.CurrencyCode, animation: BillPresentationStyle ) { track( @@ -98,7 +97,7 @@ class AnalyticsManager @Inject constructor( ) } - override fun billShown(kin: Kin, currencyCode: CurrencyCode, animation: BillPresentationStyle) { + override fun billShown(kin: Kin, currencyCode: com.getcode.model.CurrencyCode, animation: BillPresentationStyle) { track( Name.Bill, Pair(Property.State, StringValue.Shown.value), @@ -110,7 +109,7 @@ class AnalyticsManager @Inject constructor( override fun billHidden( kin: Kin, - currencyCode: CurrencyCode, + currencyCode: com.getcode.model.CurrencyCode, animation: BillPresentationStyle ) { track( @@ -155,7 +154,7 @@ class AnalyticsManager @Inject constructor( ) } - override fun remoteSendOutgoing(kin: Kin, currencyCode: CurrencyCode) { + override fun remoteSendOutgoing(kin: Kin, currencyCode: com.getcode.model.CurrencyCode) { track( Name.RemoteSendOutgoing, Property.Amount to kin.toKin().toInt().toString(), @@ -163,7 +162,7 @@ class AnalyticsManager @Inject constructor( ) } - override fun remoteSendIncoming(kin: Kin, currencyCode: CurrencyCode, isVoiding: Boolean) { + override fun remoteSendIncoming(kin: Kin, currencyCode: com.getcode.model.CurrencyCode, isVoiding: Boolean) { track( Name.RemoteSendIncoming, Property.VoidingSend to if (isVoiding) StringValue.Yes.value else StringValue.No.value, @@ -184,7 +183,7 @@ class AnalyticsManager @Inject constructor( grabStartMillis = System.currentTimeMillis() } - override fun grab(kin: Kin, currencyCode: CurrencyCode) { + override fun grab(kin: Kin, currencyCode: com.getcode.model.CurrencyCode) { if (grabStartMillis == 0L) return val millisecondsToGrab = System.currentTimeMillis() - grabStartMillis track( @@ -220,7 +219,7 @@ class AnalyticsManager @Inject constructor( cashLinkGrabStartMillis = System.currentTimeMillis() } - override fun cashLinkGrab(kin: Kin, currencyCode: CurrencyCode) { + override fun cashLinkGrab(kin: Kin, currencyCode: com.getcode.model.CurrencyCode) { if (cashLinkGrabStartMillis == 0L) return val millisecondsToGrab = System.currentTimeMillis() - cashLinkGrabStartMillis track( diff --git a/api/src/main/java/com/getcode/analytics/AnalyticsService.kt b/services/code/src/main/java/com/getcode/analytics/AnalyticsService.kt similarity index 98% rename from api/src/main/java/com/getcode/analytics/AnalyticsService.kt rename to services/code/src/main/java/com/getcode/analytics/AnalyticsService.kt index 64f744262..8e48397ad 100644 --- a/api/src/main/java/com/getcode/analytics/AnalyticsService.kt +++ b/services/code/src/main/java/com/getcode/analytics/AnalyticsService.kt @@ -1,6 +1,6 @@ package com.getcode.analytics -import com.getcode.model.AppSetting +import com.getcode.services.model.AppSetting import com.getcode.model.CurrencyCode import com.getcode.model.Kin import com.getcode.model.KinAmount diff --git a/api/src/main/java/com/getcode/annotations/DevManagedChannel.kt b/services/code/src/main/java/com/getcode/annotations/CodeManagedChannel.kt similarity index 88% rename from api/src/main/java/com/getcode/annotations/DevManagedChannel.kt rename to services/code/src/main/java/com/getcode/annotations/CodeManagedChannel.kt index d4c4af16a..9a6839090 100644 --- a/api/src/main/java/com/getcode/annotations/DevManagedChannel.kt +++ b/services/code/src/main/java/com/getcode/annotations/CodeManagedChannel.kt @@ -10,4 +10,4 @@ import javax.inject.Qualifier AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FIELD ) -annotation class DevManagedChannel \ No newline at end of file +annotation class CodeManagedChannel \ No newline at end of file diff --git a/api/src/main/java/com/getcode/db/AppDatabase.kt b/services/code/src/main/java/com/getcode/db/CodeAppDatabase.kt similarity index 55% rename from api/src/main/java/com/getcode/db/AppDatabase.kt rename to services/code/src/main/java/com/getcode/db/CodeAppDatabase.kt index 09d8624fb..1efd253cd 100644 --- a/api/src/main/java/com/getcode/db/AppDatabase.kt +++ b/services/code/src/main/java/com/getcode/db/CodeAppDatabase.kt @@ -13,15 +13,17 @@ import androidx.room.migration.AutoMigrationSpec import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.getcode.model.* -import com.getcode.network.repository.decodeBase64 +import com.getcode.services.db.ClosableDatabase +import com.getcode.services.db.SharedConverters +import com.getcode.services.model.PrefBool +import com.getcode.services.model.PrefDouble +import com.getcode.services.model.PrefInt +import com.getcode.services.model.PrefString import com.getcode.utils.TraceType import com.getcode.utils.trace -import com.getcode.vendor.Base58 import io.reactivex.rxjava3.core.BackpressureStrategy import io.reactivex.rxjava3.subjects.BehaviorSubject -import net.sqlcipher.database.SupportFactory import org.kin.sdk.base.tools.subByteArray -import timber.log.Timber import java.io.File @Database( @@ -34,37 +36,28 @@ import java.io.File PrefDouble::class, GiftCard::class, ExchangeRate::class, - Conversation::class, - ConversationPointerCrossRef::class, - ConversationMessage::class, - ConversationIntentIdReference::class, - ConversationMessageContent::class, ], autoMigrations = [ - AutoMigration(from = 7, to = 8, spec = AppDatabase.Migration7To8::class), - AutoMigration(from = 8, to = 9, spec = AppDatabase.Migration8To9::class), - AutoMigration(from = 10, to = 11, spec = AppDatabase.Migration10To11::class), - AutoMigration(from = 11, to = 12, spec = AppDatabase.Migration11To12::class), - AutoMigration(from = 12, to = 13, spec = AppDatabase.Migration12To13::class), - AutoMigration(from = 13, to = 14, spec = AppDatabase.Migration13To14::class), - AutoMigration(from = 14, to = 15, spec = AppDatabase.Migration14To15::class), + AutoMigration(from = 7, to = 8, spec = CodeAppDatabase.Migration7To8::class), + AutoMigration(from = 8, to = 9, spec = CodeAppDatabase.Migration8To9::class), + AutoMigration(from = 10, to = 11, spec = CodeAppDatabase.Migration10To11::class), + AutoMigration(from = 11, to = 12, spec = CodeAppDatabase.Migration11To12::class), + AutoMigration(from = 12, to = 13, spec = CodeAppDatabase.Migration12To13::class), + AutoMigration(from = 13, to = 14, spec = CodeAppDatabase.Migration13To14::class), + AutoMigration(from = 14, to = 15, spec = CodeAppDatabase.Migration14To15::class), + AutoMigration(from = 15, to = 16, spec = CodeAppDatabase.Migration15To16::class), ], - version = 15 + version = 16 ) -@TypeConverters(Converters::class) -abstract class AppDatabase : RoomDatabase() { - abstract fun prefIntDao(): PrefIntDao - abstract fun prefStringDao(): PrefStringDao - abstract fun prefBoolDao(): PrefBoolDao - abstract fun prefDoubleDao(): PrefDoubleDao +@TypeConverters(SharedConverters::class) +abstract class CodeAppDatabase : RoomDatabase(), ClosableDatabase { + abstract fun prefIntDao(): RxPrefIntDao + abstract fun prefStringDao(): RxPrefStringDao + abstract fun prefBoolDao(): RxPrefBoolDao + abstract fun prefDoubleDao(): RxPrefDoubleDao abstract fun giftCardDao(): GiftCardDao abstract fun exchangeDao(): ExchangeDao - abstract fun conversationDao(): ConversationDao - abstract fun conversationPointersDao(): ConversationPointerDao - abstract fun conversationMessageDao(): ConversationMessageDao - abstract fun conversationIntentMappingDao(): ConversationIntentMappingDao - @DeleteTable(tableName = "HistoricalTransaction") class Migration7To8 : AutoMigrationSpec @@ -123,7 +116,7 @@ abstract class AppDatabase : RoomDatabase() { columnName = "status" ) ) - class Migration13To14: AutoMigrationSpec + class Migration13To14 : AutoMigrationSpec @DeleteColumn.Entries( DeleteColumn( @@ -135,44 +128,25 @@ abstract class AppDatabase : RoomDatabase() { columnName = "userImage" ) ) - class Migration14To15: AutoMigrationSpec -} - -object Database { - private const val dbNamePrefix = "AppDatabase" - private const val dbNameSuffix = ".db" - private var instance: AppDatabase? = null - private var dbName: String = "" - private var isInitSubject: BehaviorSubject = BehaviorSubject.create() - var isInit = isInitSubject.toFlowable(BackpressureStrategy.DROP).filter { true } - fun isOpen() = instance?.isOpen == true - fun getInstance() = instance - fun requireInstance() = instance!! - - fun init(context: Context, entropyB64: String) { - trace("database init start", type = TraceType.Process) - instance?.close() - val dbUniqueName = Base58.encode(entropyB64.toByteArray().subByteArray(0, 3)) - dbName = "$dbNamePrefix-$dbUniqueName$dbNameSuffix" - - instance = - Room.databaseBuilder(context, AppDatabase::class.java, dbName) -// .openHelperFactory(SupportFactory(entropyB64.decodeBase64(), null, false)) - .fallbackToDestructiveMigration() - .build() - - isInitSubject.onNext(true) - trace("database init end", type = TraceType.Process) - } - - fun close() { - Timber.d("close") + class Migration14To15 : AutoMigrationSpec + + /** + * Chat removed from Code and moved to Flipchat + */ + @DeleteTable("conversations") + @DeleteTable("conversation_pointers") + @DeleteTable("messages") + @DeleteTable("conversation_intent_id_mapping") + @DeleteTable("message_contents") + class Migration15To16 : AutoMigrationSpec + + override fun closeDb() { instance?.close() instance = null isInitSubject.onNext(false) } - fun delete(context: Context) { + override fun deleteDb(context: Context) { instance?.close() if (dbName.isEmpty()) return @@ -189,4 +163,34 @@ object Database { if (wal.exists()) shm.delete() isInitSubject.onNext(false) } + + companion object { + private var instance: CodeAppDatabase? = null + private var isInitSubject: BehaviorSubject = BehaviorSubject.create() + var isInit = isInitSubject.toFlowable(BackpressureStrategy.DROP).filter { true } + fun isOpen() = instance?.isOpen == true + fun getInstance() = instance + fun requireInstance() = instance!! + private var dbName: String = "" + + private const val dbNamePrefix = "AppDatabase" + private const val dbNameSuffix = ".db" + + fun init(context: Context, entropyB64: String) { + trace("database init start", type = TraceType.Process) + instance?.close() + val dbUniqueName = + com.getcode.vendor.Base58.encode(entropyB64.toByteArray().subByteArray(0, 3)) + dbName = "$dbNamePrefix-$dbUniqueName$dbNameSuffix" + + instance = + Room.databaseBuilder(context, CodeAppDatabase::class.java, dbName) +// .openHelperFactory(SupportFactory(entropyB64.decodeBase64(), null, false)) + .fallbackToDestructiveMigration() + .build() + + isInitSubject.onNext(true) + trace("database init end", type = TraceType.Process) + } + } } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/db/ExchangeDao.kt b/services/code/src/main/java/com/getcode/db/ExchangeDao.kt similarity index 100% rename from api/src/main/java/com/getcode/db/ExchangeDao.kt rename to services/code/src/main/java/com/getcode/db/ExchangeDao.kt diff --git a/api/src/main/java/com/getcode/db/GiftCardDao.kt b/services/code/src/main/java/com/getcode/db/GiftCardDao.kt similarity index 100% rename from api/src/main/java/com/getcode/db/GiftCardDao.kt rename to services/code/src/main/java/com/getcode/db/GiftCardDao.kt diff --git a/api/src/main/java/com/getcode/db/InMemoryDao.kt b/services/code/src/main/java/com/getcode/db/InMemoryDao.kt similarity index 100% rename from api/src/main/java/com/getcode/db/InMemoryDao.kt rename to services/code/src/main/java/com/getcode/db/InMemoryDao.kt diff --git a/api/src/main/java/com/getcode/db/PrefBoolDao.kt b/services/code/src/main/java/com/getcode/db/RxPrefBoolDao.kt similarity index 90% rename from api/src/main/java/com/getcode/db/PrefBoolDao.kt rename to services/code/src/main/java/com/getcode/db/RxPrefBoolDao.kt index 203279ead..807f26fca 100644 --- a/api/src/main/java/com/getcode/db/PrefBoolDao.kt +++ b/services/code/src/main/java/com/getcode/db/RxPrefBoolDao.kt @@ -4,13 +4,13 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import com.getcode.model.PrefBool +import com.getcode.services.model.PrefBool import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Maybe import kotlinx.coroutines.flow.Flow @Dao -interface PrefBoolDao { +interface RxPrefBoolDao { @Query("SELECT * FROM PrefBool WHERE key = :key") fun get(key: String): Flowable @Query("SELECT * FROM PrefBool WHERE key = :key") diff --git a/api/src/main/java/com/getcode/db/PrefDoubleDao.kt b/services/code/src/main/java/com/getcode/db/RxPrefDoubleDao.kt similarity index 87% rename from api/src/main/java/com/getcode/db/PrefDoubleDao.kt rename to services/code/src/main/java/com/getcode/db/RxPrefDoubleDao.kt index 998393c6f..afccac2eb 100644 --- a/api/src/main/java/com/getcode/db/PrefDoubleDao.kt +++ b/services/code/src/main/java/com/getcode/db/RxPrefDoubleDao.kt @@ -4,14 +4,13 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import com.getcode.model.PrefBool -import com.getcode.model.PrefDouble +import com.getcode.services.model.PrefDouble import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Maybe import kotlinx.coroutines.flow.Flow @Dao -interface PrefDoubleDao { +interface RxPrefDoubleDao { @Query("SELECT * FROM PrefDouble WHERE `key` = :key") fun get(key: String): Flowable diff --git a/api/src/main/java/com/getcode/db/PrefIntDao.kt b/services/code/src/main/java/com/getcode/db/RxPrefIntDao.kt similarity index 87% rename from api/src/main/java/com/getcode/db/PrefIntDao.kt rename to services/code/src/main/java/com/getcode/db/RxPrefIntDao.kt index 32c8ba9a7..4ca5263bf 100644 --- a/api/src/main/java/com/getcode/db/PrefIntDao.kt +++ b/services/code/src/main/java/com/getcode/db/RxPrefIntDao.kt @@ -4,14 +4,13 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import com.getcode.model.PrefBool -import com.getcode.model.PrefInt +import com.getcode.services.model.PrefInt import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Maybe import kotlinx.coroutines.flow.Flow @Dao -interface PrefIntDao { +interface RxPrefIntDao { @Query("SELECT * FROM PrefInt WHERE key = :key") fun get(key: String): Flowable diff --git a/api/src/main/java/com/getcode/db/PrefStringDao.kt b/services/code/src/main/java/com/getcode/db/RxPrefStringDao.kt similarity index 87% rename from api/src/main/java/com/getcode/db/PrefStringDao.kt rename to services/code/src/main/java/com/getcode/db/RxPrefStringDao.kt index 28fd7f4af..abad6f804 100644 --- a/api/src/main/java/com/getcode/db/PrefStringDao.kt +++ b/services/code/src/main/java/com/getcode/db/RxPrefStringDao.kt @@ -4,14 +4,13 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import com.getcode.model.PrefBool -import com.getcode.model.PrefString +import com.getcode.services.model.PrefString import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Maybe import kotlinx.coroutines.flow.Flow @Dao -interface PrefStringDao { +interface RxPrefStringDao { @Query("SELECT * FROM PrefString WHERE key = :key") fun get(key: String): Flowable diff --git a/api/src/main/java/com/getcode/generator/Generator.kt b/services/code/src/main/java/com/getcode/generator/Generator.kt similarity index 100% rename from api/src/main/java/com/getcode/generator/Generator.kt rename to services/code/src/main/java/com/getcode/generator/Generator.kt diff --git a/api/src/main/java/com/getcode/generator/GiftCardGenerator.kt b/services/code/src/main/java/com/getcode/generator/GiftCardGenerator.kt similarity index 100% rename from api/src/main/java/com/getcode/generator/GiftCardGenerator.kt rename to services/code/src/main/java/com/getcode/generator/GiftCardGenerator.kt diff --git a/api/src/main/java/com/getcode/generator/MnemonicGenerator.kt b/services/code/src/main/java/com/getcode/generator/MnemonicGenerator.kt similarity index 82% rename from api/src/main/java/com/getcode/generator/MnemonicGenerator.kt rename to services/code/src/main/java/com/getcode/generator/MnemonicGenerator.kt index 740f5691a..f2ca12d11 100644 --- a/api/src/main/java/com/getcode/generator/MnemonicGenerator.kt +++ b/services/code/src/main/java/com/getcode/generator/MnemonicGenerator.kt @@ -1,8 +1,8 @@ package com.getcode.generator import com.getcode.crypt.MnemonicPhrase -import com.getcode.utils.Base58String -import com.getcode.utils.Base64String +import com.getcode.services.utils.Base58String +import com.getcode.services.utils.Base64String import javax.inject.Inject class MnemonicGenerator @Inject constructor( diff --git a/api/src/main/java/com/getcode/generator/OrganizerGenerator.kt b/services/code/src/main/java/com/getcode/generator/OrganizerGenerator.kt similarity index 100% rename from api/src/main/java/com/getcode/generator/OrganizerGenerator.kt rename to services/code/src/main/java/com/getcode/generator/OrganizerGenerator.kt diff --git a/services/code/src/main/java/com/getcode/inject/CodeApiModule.kt b/services/code/src/main/java/com/getcode/inject/CodeApiModule.kt new file mode 100644 index 000000000..915374027 --- /dev/null +++ b/services/code/src/main/java/com/getcode/inject/CodeApiModule.kt @@ -0,0 +1,190 @@ +package com.getcode.inject + +import android.content.Context +import com.getcode.CodeServicesConfig +import com.getcode.analytics.AnalyticsService +import com.getcode.analytics.AnalyticsServiceNull +import com.getcode.annotations.CodeManagedChannel +import com.getcode.libs.logging.BuildConfig +import com.getcode.network.BalanceController +import com.getcode.network.PrivacyMigration +import com.getcode.network.api.TransactionApiV2 +import com.getcode.network.client.Client +import com.getcode.network.client.TransactionReceiver +import com.getcode.network.exchange.CodeExchange +import com.getcode.network.exchange.Exchange +import com.getcode.network.repository.AccountRepository +import com.getcode.network.repository.BalanceRepository +import com.getcode.network.repository.IdentityRepository +import com.getcode.network.repository.MessagingRepository +import com.getcode.network.repository.PrefRepository +import com.getcode.network.repository.TransactionRepository +import com.getcode.network.service.AccountService +import com.getcode.network.service.ChatService +import com.getcode.network.service.CurrencyService +import com.getcode.network.service.DeviceService +import com.getcode.services.db.CurrencyProvider +import com.getcode.services.manager.MnemonicManager +import com.getcode.services.network.core.NetworkOracle +import com.getcode.services.network.core.NetworkOracleImpl +import com.getcode.services.utils.logging.LoggingClientInterceptor +import com.getcode.utils.CurrencyUtils +import com.getcode.utils.network.NetworkConnectivityListener +import com.mixpanel.android.mpmetrics.MixpanelAPI +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import io.grpc.ManagedChannel +import io.grpc.android.AndroidChannelBuilder +import org.kin.sdk.base.network.api.agora.OkHttpChannelBuilderForcedTls12 +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object CodeApiModule { + + @Provides + fun provideNetworkOracle(): NetworkOracle { + return NetworkOracleImpl() + } + + @Singleton + @Provides + fun providesServicesConfig(): CodeServicesConfig { + return CodeServicesConfig() + } + + @Singleton + @Provides + @CodeManagedChannel + fun provideManagedChannel( + @ApplicationContext context: Context, + config: CodeServicesConfig, + ): ManagedChannel { + return AndroidChannelBuilder + .usingBuilder(OkHttpChannelBuilderForcedTls12.forAddress(config.baseUrl, config.port)) + .context(context) + .userAgent(config.userAgent) + .keepAliveTime(config.keepAlive.inWholeMilliseconds, TimeUnit.MILLISECONDS) + .apply { + if (BuildConfig.DEBUG) { + this.intercept(LoggingClientInterceptor()) + } + } + .build() + } + + @Singleton + @Provides + fun provideClient( + identityRepository: IdentityRepository, + transactionRepository: TransactionRepository, + messagingRepository: MessagingRepository, + accountRepository: AccountRepository, + accountService: AccountService, + balanceController: BalanceController, + analytics: AnalyticsService, + prefRepository: PrefRepository, + transactionReceiver: TransactionReceiver, + exchange: Exchange, + networkObserver: NetworkConnectivityListener, + chatService: ChatService, + deviceService: DeviceService, + mnemonicManager: MnemonicManager, + ): Client { + return Client( + identityRepository, + transactionRepository, + messagingRepository, + balanceController, + accountRepository, + accountService, + analytics, + prefRepository, + exchange, + transactionReceiver, + networkObserver, + chatService, + deviceService, + mnemonicManager + ) + } + + @Singleton + @Provides + fun provideBalanceRepository( + ): BalanceRepository { + return BalanceRepository() + } + + @Singleton + @Provides + fun provideBalanceController( + exchange: Exchange, + balanceRepository: BalanceRepository, + transactionRepository: TransactionRepository, + accountRepository: AccountRepository, + privacyMigration: PrivacyMigration, + transactionReceiver: TransactionReceiver, + networkObserver: NetworkConnectivityListener, + currencyUtils: CurrencyUtils, + currencyProvider: CurrencyProvider, + ): BalanceController { + return BalanceController( + exchange = exchange, + balanceRepository = balanceRepository, + transactionRepository = transactionRepository, + accountRepository = accountRepository, + privacyMigration = privacyMigration, + transactionReceiver = transactionReceiver, + networkObserver = networkObserver, + getCurrencyFromCode = { + it?.name?.let(currencyUtils::getCurrency) + }, + suffix = { currency -> currencyProvider.suffix(currency) } + ) + } + + @Singleton + @Provides + fun providesExchange( + currencyService: CurrencyService, + prefRepository: PrefRepository, + currencyProvider: CurrencyProvider, + ): Exchange = CodeExchange( + currencyService = currencyService, + prefs = prefRepository, + preferredCurrency = { currencyProvider.preferredCurrency() }, + defaultCurrency = { currencyProvider.defaultCurrency() } + ) + + @Singleton + @Provides + fun providePrivacyMigration( + transactionRepository: TransactionRepository, + analytics: AnalyticsService, + ): PrivacyMigration { + return PrivacyMigration( + transactionRepository, + analytics + ) + } + + @Singleton + @Provides + fun provideTransactionRepository( + @ApplicationContext context: Context, + transactionApi: TransactionApiV2, + ): TransactionRepository { + return TransactionRepository(transactionApi = transactionApi, context = context) + } + + // TODO: + @Provides + fun providesAnalyticsService( + mixpanelAPI: MixpanelAPI + ): AnalyticsService = AnalyticsServiceNull() +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/manager/GiftCardManager.kt b/services/code/src/main/java/com/getcode/manager/GiftCardManager.kt similarity index 100% rename from api/src/main/java/com/getcode/manager/GiftCardManager.kt rename to services/code/src/main/java/com/getcode/manager/GiftCardManager.kt diff --git a/api/src/main/java/com/getcode/manager/ModalManager.kt b/services/code/src/main/java/com/getcode/manager/ModalManager.kt similarity index 100% rename from api/src/main/java/com/getcode/manager/ModalManager.kt rename to services/code/src/main/java/com/getcode/manager/ModalManager.kt diff --git a/api/src/main/java/com/getcode/manager/SessionManager.kt b/services/code/src/main/java/com/getcode/manager/SessionManager.kt similarity index 96% rename from api/src/main/java/com/getcode/manager/SessionManager.kt rename to services/code/src/main/java/com/getcode/manager/SessionManager.kt index ecbef6e80..da13cfbb6 100644 --- a/api/src/main/java/com/getcode/manager/SessionManager.kt +++ b/services/code/src/main/java/com/getcode/manager/SessionManager.kt @@ -5,8 +5,9 @@ import com.getcode.generator.OrganizerGenerator import com.getcode.network.client.Client import com.getcode.network.client.registerInstallation import com.getcode.network.client.updatePreferences +import com.getcode.services.manager.MnemonicManager import com.getcode.solana.organizer.Organizer -import com.getcode.utils.installationId +import com.getcode.services.utils.installationId import com.getcode.utils.trace import com.google.firebase.Firebase import com.google.firebase.installations.installations diff --git a/services/code/src/main/java/com/getcode/mapper/ChatMessageMapper.kt b/services/code/src/main/java/com/getcode/mapper/ChatMessageMapper.kt new file mode 100644 index 000000000..2c498ae76 --- /dev/null +++ b/services/code/src/main/java/com/getcode/mapper/ChatMessageMapper.kt @@ -0,0 +1,30 @@ +package com.getcode.mapper + + +import com.getcode.model.chat.ChatMessage +import com.getcode.model.chat.MessageContent +import com.getcode.model.protomapping.invoke +import com.getcode.services.mapper.Mapper +import javax.inject.Inject +import com.codeinc.gen.chat.v1.ChatService.ChatMessage as ApiChatMessage +import com.getcode.model.chat.ChatMessage as DomainChatMessage + +class ChatMessageMapper @Inject constructor( +): Mapper { + override fun map(from: ApiChatMessage): ChatMessage { + + val messageId = from.messageId.value.toList() + val contents = from.contentList.mapNotNull { MessageContent.invoke(it, messageId) } + val isFromSelf = contents.any { it.isFromSelf } + + return ChatMessage( + id = messageId, + senderId = emptyList(), + isFromSelf = isFromSelf, + cursor = from.cursor.value.toList(), + dateMillis = from.ts.seconds * 1_000L, + contents = contents, +// status = if (isFromSelf) MessageStatus.Sent else MessageStatus.Incoming + ) + } +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/mapper/ChatMetadataV1Mapper.kt b/services/code/src/main/java/com/getcode/mapper/ChatMetadataMapper.kt similarity index 83% rename from api/src/main/java/com/getcode/mapper/ChatMetadataV1Mapper.kt rename to services/code/src/main/java/com/getcode/mapper/ChatMetadataMapper.kt index d72174411..ff4dc02ca 100644 --- a/api/src/main/java/com/getcode/mapper/ChatMetadataV1Mapper.kt +++ b/services/code/src/main/java/com/getcode/mapper/ChatMetadataMapper.kt @@ -2,12 +2,11 @@ package com.getcode.mapper import com.codeinc.gen.chat.v1.ChatService import com.getcode.model.chat.Chat -import com.getcode.model.chat.ChatType import com.getcode.model.chat.Title +import com.getcode.services.mapper.Mapper import javax.inject.Inject -@Deprecated("Replaced by V2") -class ChatMetadataV1Mapper @Inject constructor( +class ChatMetadataMapper @Inject constructor( ) : Mapper { override fun map(from: ChatService.ChatMetadata): Chat { return Chat( @@ -16,7 +15,6 @@ class ChatMetadataV1Mapper @Inject constructor( cursor = from.cursor.value.toByteArray().toList(), canMute = from.canMute, canUnsubscribe = from.canUnsubscribe, - type = ChatType.Notification, messages = emptyList(), // backwards compatibility fields - these are derived from [Chat#members] in V2 _unreadCount = from.numUnread, diff --git a/api/src/main/java/com/getcode/model/AccountInfo.kt b/services/code/src/main/java/com/getcode/model/AccountInfo.kt similarity index 95% rename from api/src/main/java/com/getcode/model/AccountInfo.kt rename to services/code/src/main/java/com/getcode/model/AccountInfo.kt index 5e01739e4..d440b9829 100644 --- a/api/src/main/java/com/getcode/model/AccountInfo.kt +++ b/services/code/src/main/java/com/getcode/model/AccountInfo.kt @@ -1,7 +1,6 @@ package com.getcode.model import com.codeinc.gen.account.v1.AccountService -import com.getcode.solana.keys.PublicKey import com.getcode.solana.organizer.AccountType data class AccountInfo ( @@ -13,17 +12,17 @@ data class AccountInfo ( var accountType: AccountType, /// The token account's address - var address: PublicKey, + var address: com.getcode.solana.keys.PublicKey, /// The owner of the token account, which can also be thought of as a parent /// account that links to one or more token accounts. This is provided when /// available. - var owner: PublicKey?, + var owner: com.getcode.solana.keys.PublicKey?, /// The token account's authority, which has access to moving funds for the /// account. This can be the owner account under certain circumstances (eg. /// ATA, primary account). This is provided when available. - var authority: PublicKey?, + var authority: com.getcode.solana.keys.PublicKey?, /// The source of truth for the balance calculation. var balanceSource: BalanceSource, @@ -68,19 +67,21 @@ data class AccountInfo ( // the time created on the blockchain. var createdAt: Long?, -) { + ) { companion object { fun newInstance(info: AccountService.TokenAccountInfo): AccountInfo? { val accountType = AccountType.newInstance(info.accountType, info.relationship) ?: return null - val address = PublicKey(info.address.value.toByteArray().toList()) + val address = + com.getcode.solana.keys.PublicKey(info.address.value.toByteArray().toList()) val balanceSource = BalanceSource.getInstance(info.balanceSource) ?: return null val managementState = ManagementState.getInstance(info.managementState) ?: return null val blockchainState = BlockchainState.getInstance(info.blockchainState) ?: return null val claimState = ClaimState.getInstance(info.claimState) ?: return null - val owner = PublicKey(info.owner.value.toByteArray().toList()) - val authority = PublicKey(info.authority.value.toByteArray().toList()) + val owner = com.getcode.solana.keys.PublicKey(info.owner.value.toByteArray().toList()) + val authority = + com.getcode.solana.keys.PublicKey(info.authority.value.toByteArray().toList()) val originalCurrency = CurrencyCode.tryValueOf(info.originalExchangeData.currency) diff --git a/api/src/main/java/com/getcode/model/AirdropType.kt b/services/code/src/main/java/com/getcode/model/AirdropType.kt similarity index 100% rename from api/src/main/java/com/getcode/model/AirdropType.kt rename to services/code/src/main/java/com/getcode/model/AirdropType.kt diff --git a/api/src/main/java/com/getcode/model/ClientSignature.kt b/services/code/src/main/java/com/getcode/model/ClientSignature.kt similarity index 100% rename from api/src/main/java/com/getcode/model/ClientSignature.kt rename to services/code/src/main/java/com/getcode/model/ClientSignature.kt diff --git a/api/src/main/java/com/getcode/model/CurrencyRate.kt b/services/code/src/main/java/com/getcode/model/CurrencyRate.kt similarity index 100% rename from api/src/main/java/com/getcode/model/CurrencyRate.kt rename to services/code/src/main/java/com/getcode/model/CurrencyRate.kt diff --git a/api/src/main/java/com/getcode/model/Rate.kt b/services/code/src/main/java/com/getcode/model/ExchangeRate.kt similarity index 50% rename from api/src/main/java/com/getcode/model/Rate.kt rename to services/code/src/main/java/com/getcode/model/ExchangeRate.kt index 3be171699..8c0ef5b6b 100644 --- a/api/src/main/java/com/getcode/model/Rate.kt +++ b/services/code/src/main/java/com/getcode/model/ExchangeRate.kt @@ -3,21 +3,10 @@ package com.getcode.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +import com.codeinc.gen.transaction.v2.TransactionService import kotlinx.serialization.Serializable -@Serializable -data class Rate( - val fx: Double, - val currency: CurrencyCode -) { - companion object { - val oneToOne = Rate(fx = 1.0, currency = CurrencyCode.KIN) - } -} - -fun Rate?.orOneToOne() = this ?: Rate.oneToOne - @Serializable @Entity(tableName = "exchangeData") data class ExchangeRate( @@ -27,4 +16,13 @@ data class ExchangeRate( val currency: CurrencyCode, @ColumnInfo(name = "synced_at") val synced: Long, -) \ No newline at end of file +) + +fun KinAmount.Companion.fromProtoExchangeData(exchangeData: TransactionService.ExchangeData): KinAmount { + return fromFiatAmount( + kin = Kin(exchangeData.quarks), + fiat = exchangeData.nativeAmount, + fx = exchangeData.exchangeRate, + currencyCode = CurrencyCode.tryValueOf(exchangeData.currency)!! + ) +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/FaqItem.kt b/services/code/src/main/java/com/getcode/model/FaqItem.kt similarity index 100% rename from api/src/main/java/com/getcode/model/FaqItem.kt rename to services/code/src/main/java/com/getcode/model/FaqItem.kt diff --git a/api/src/main/java/com/getcode/model/Feature.kt b/services/code/src/main/java/com/getcode/model/Feature.kt similarity index 91% rename from api/src/main/java/com/getcode/model/Feature.kt rename to services/code/src/main/java/com/getcode/model/Feature.kt index 4b16e474c..875521a28 100644 --- a/api/src/main/java/com/getcode/model/Feature.kt +++ b/services/code/src/main/java/com/getcode/model/Feature.kt @@ -22,11 +22,6 @@ data class TipCardOnHomeScreenFeature( override val available: Boolean = true, // always available ): Feature -data class ConversationsFeature( - override val enabled: Boolean = BetaOptions.Defaults.conversationsEnabled, - override val available: Boolean = true, // always available -): Feature - data class ConversationCashFeature( override val enabled: Boolean = BetaOptions.Defaults.conversationCashEnabled, override val available: Boolean = true, // always available diff --git a/api/src/main/java/com/getcode/model/GiftCard.kt b/services/code/src/main/java/com/getcode/model/GiftCard.kt similarity index 100% rename from api/src/main/java/com/getcode/model/GiftCard.kt rename to services/code/src/main/java/com/getcode/model/GiftCard.kt diff --git a/api/src/main/java/com/getcode/model/Intent.kt b/services/code/src/main/java/com/getcode/model/Intent.kt similarity index 100% rename from api/src/main/java/com/getcode/model/Intent.kt rename to services/code/src/main/java/com/getcode/model/Intent.kt diff --git a/api/src/main/java/com/getcode/model/IntentMetadata.kt b/services/code/src/main/java/com/getcode/model/IntentMetadata.kt similarity index 100% rename from api/src/main/java/com/getcode/model/IntentMetadata.kt rename to services/code/src/main/java/com/getcode/model/IntentMetadata.kt diff --git a/api/src/main/java/com/getcode/model/KinCode.kt b/services/code/src/main/java/com/getcode/model/KinCode.kt similarity index 100% rename from api/src/main/java/com/getcode/model/KinCode.kt rename to services/code/src/main/java/com/getcode/model/KinCode.kt diff --git a/api/src/main/java/com/getcode/model/Limits.kt b/services/code/src/main/java/com/getcode/model/Limits.kt similarity index 99% rename from api/src/main/java/com/getcode/model/Limits.kt rename to services/code/src/main/java/com/getcode/model/Limits.kt index c35c97f01..6a3384f38 100644 --- a/api/src/main/java/com/getcode/model/Limits.kt +++ b/services/code/src/main/java/com/getcode/model/Limits.kt @@ -23,7 +23,7 @@ data class Limits( // Buy limits keyed by currency private val buyLimits: Map, -) { + ) { val isStale: Boolean get() { val now = System.currentTimeMillis() diff --git a/api/src/main/java/com/getcode/model/PaymentRequest.kt b/services/code/src/main/java/com/getcode/model/PaymentRequest.kt similarity index 83% rename from api/src/main/java/com/getcode/model/PaymentRequest.kt rename to services/code/src/main/java/com/getcode/model/PaymentRequest.kt index 6c77f9721..584cfa7c6 100644 --- a/api/src/main/java/com/getcode/model/PaymentRequest.kt +++ b/services/code/src/main/java/com/getcode/model/PaymentRequest.kt @@ -1,8 +1,8 @@ package com.getcode.model import com.codeinc.gen.messaging.v1.MessagingService -import com.getcode.solana.keys.Signature import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.Signature data class StreamMessage(val id: List, val kind: Kind) { sealed interface Kind { @@ -26,7 +26,9 @@ data class StreamMessage(val id: List, val kind: Kind) { message.requestToGrabBill.requestorAccount.value.toByteArray().toList() ) val signature = - Signature(message.sendMessageRequestSignature.value.toByteArray().toList()) + Signature( + message.sendMessageRequestSignature.value.toByteArray().toList() + ) Kind.PaymentRequestKind( PaymentRequest( @@ -38,14 +40,20 @@ data class StreamMessage(val id: List, val kind: Kind) { MessagingService.Message.KindCase.REQUEST_TO_RECEIVE_BILL -> { val request = message.requestToReceiveBill val exchangeData = request.exchangeDataCase - val account = PublicKey(request.requestorAccount.value.toByteArray().toList()) - val signature = Signature(message.sendMessageRequestSignature.value.toByteArray().toList()) + val account = PublicKey( + request.requestorAccount.value.toByteArray().toList() + ) + val signature = Signature( + message.sendMessageRequestSignature.value.toByteArray().toList() + ) val domain: Domain? val verifier: PublicKey? if (request.hasDomain()) { val validDomain = Domain.from(request.domain.value) ?: return null - val validVerifier = PublicKey(request.verifier.value.toByteArray().toList()) + val validVerifier = PublicKey( + request.verifier.value.toByteArray().toList() + ) domain = validDomain verifier = validVerifier @@ -60,7 +68,9 @@ data class StreamMessage(val id: List, val kind: Kind) { val currency = CurrencyCode.tryValueOf(data.currency) ?: return null val additionalFees = request.additionalFeesList.mapNotNull { - val destination = PublicKey(it.destination.value.toByteArray().toList()) + val destination = PublicKey( + it.destination.value.toByteArray().toList() + ) Fee(destination = destination, it.feeBps) } @@ -87,7 +97,9 @@ data class StreamMessage(val id: List, val kind: Kind) { val additionalFees = request.additionalFeesList.mapNotNull { - val destination = PublicKey(it.destination.value.toByteArray().toList()) + val destination = PublicKey( + it.destination.value.toByteArray().toList() + ) Fee(destination = destination, it.feeBps) } @@ -132,9 +144,15 @@ data class StreamMessage(val id: List, val kind: Kind) { MessagingService.Message.KindCase.REQUEST_TO_LOGIN -> { val request = message.requestToLogin val domain = request.domain?.let { Domain.from(it.value) } ?: return null - val verifier = PublicKey(request.verifier.value.toByteArray().toList()) - val rendezvous = PublicKey(request.rendezvousKey.toByteArray().toList()) - val signature = Signature(request.signature.value.toByteArray().toList()) + val verifier = PublicKey( + request.verifier.value.toByteArray().toList() + ) + val rendezvous = PublicKey( + request.rendezvousKey.toByteArray().toList() + ) + val signature = Signature( + request.signature.value.toByteArray().toList() + ) Kind.LoginRequestKind( LoginRequest(domain, verifier, rendezvous, signature) diff --git a/api/src/main/java/com/getcode/model/RelationshipBox.kt b/services/code/src/main/java/com/getcode/model/RelationshipBox.kt similarity index 95% rename from api/src/main/java/com/getcode/model/RelationshipBox.kt rename to services/code/src/main/java/com/getcode/model/RelationshipBox.kt index 932953ff5..19c77304b 100644 --- a/api/src/main/java/com/getcode/model/RelationshipBox.kt +++ b/services/code/src/main/java/com/getcode/model/RelationshipBox.kt @@ -3,8 +3,6 @@ package com.getcode.model import com.getcode.solana.keys.PublicKey import com.getcode.solana.organizer.Relationship import okhttp3.internal.toImmutableMap -import timber.log.Timber -import java.util.Comparator import javax.inject.Inject import javax.inject.Singleton diff --git a/api/src/main/java/com/getcode/model/SendDestination.kt b/services/code/src/main/java/com/getcode/model/SendDestination.kt similarity index 100% rename from api/src/main/java/com/getcode/model/SendDestination.kt rename to services/code/src/main/java/com/getcode/model/SendDestination.kt diff --git a/api/src/main/java/com/getcode/model/StreamEvent.kt b/services/code/src/main/java/com/getcode/model/StreamEvent.kt similarity index 100% rename from api/src/main/java/com/getcode/model/StreamEvent.kt rename to services/code/src/main/java/com/getcode/model/StreamEvent.kt diff --git a/api/src/main/java/com/getcode/model/UpgradeableIntent.kt b/services/code/src/main/java/com/getcode/model/UpgradeableIntent.kt similarity index 100% rename from api/src/main/java/com/getcode/model/UpgradeableIntent.kt rename to services/code/src/main/java/com/getcode/model/UpgradeableIntent.kt diff --git a/api/src/main/java/com/getcode/model/UpgradeablePrivateAction.kt b/services/code/src/main/java/com/getcode/model/UpgradeablePrivateAction.kt similarity index 90% rename from api/src/main/java/com/getcode/model/UpgradeablePrivateAction.kt rename to services/code/src/main/java/com/getcode/model/UpgradeablePrivateAction.kt index bc4067f2e..402d49e1c 100644 --- a/api/src/main/java/com/getcode/model/UpgradeablePrivateAction.kt +++ b/services/code/src/main/java/com/getcode/model/UpgradeablePrivateAction.kt @@ -1,12 +1,12 @@ package com.getcode.model import com.codeinc.gen.transaction.v2.TransactionService -import com.getcode.solana.keys.Hash -import com.getcode.solana.keys.Signature import com.getcode.solana.SolanaTransaction import com.getcode.solana.instructions.programs.SystemProgram_AdvanceNonce import com.getcode.solana.instructions.programs.TimelockProgram_TransferWithAuthority +import com.getcode.solana.keys.Hash import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.Signature import com.getcode.solana.organizer.AccountType class UpgradeablePrivateAction( @@ -69,14 +69,20 @@ class UpgradeablePrivateAction( } fun newInstance(proto: TransactionService.UpgradeableIntent.UpgradeablePrivateAction): UpgradeablePrivateAction { - val signature = Signature(proto.clientSignature.value.toByteArray().toList()) + val signature = Signature( + proto.clientSignature.value.toByteArray().toList() + ) val accountType = AccountType.newInstance(proto.sourceAccountType) ?: throw UpgradeablePrivateActionException.DeserializationFailedException() val originalDestination = - PublicKey(proto.originalDestination.value.toByteArray().toList()) - val treasury = PublicKey(proto.treasury.value.toByteArray().toList()) - val recentRoot = Hash(proto.recentRoot.value.toByteArray().toList()) + PublicKey( + proto.originalDestination.value.toByteArray().toList() + ) + val treasury = + PublicKey(proto.treasury.value.toByteArray().toList()) + val recentRoot = + Hash(proto.recentRoot.value.toByteArray().toList()) return newInstance( id = proto.actionId, diff --git a/services/code/src/main/java/com/getcode/model/chat/Chat.kt b/services/code/src/main/java/com/getcode/model/chat/Chat.kt new file mode 100644 index 000000000..0212e4fb5 --- /dev/null +++ b/services/code/src/main/java/com/getcode/model/chat/Chat.kt @@ -0,0 +1,66 @@ +package com.getcode.model.chat + +import com.getcode.model.Cursor +import com.getcode.model.ID +import kotlinx.serialization.Serializable + +/** + * Chat domain model for On-Chain messaging. This serves as a reference to a collection of messages. + * + * @param id Unique chat identifier ([ID]) + * @param title The chat title, which will be localized by server when applicable + * @param canMute Can the user mute this chat? + * @param canUnsubscribe Can the user unsubscribe from this chat? + * @param cursor [Cursor] value for this chat for reference in subsequent GetChatsRequest + * @param messages List of messages within this chat + */ +@Serializable +data class Chat( + val id: ID, + val title: Title?, + private val _unreadCount: Int = 0, + private val _isMuted: Boolean = false, + val canMute: Boolean, + private val _isSubscribed: Boolean = false, + val canUnsubscribe: Boolean, + val cursor: Cursor, + val messages: List +) { + val imageData: Any + get() { + return id + } + + val unreadCount: Int + get() { + return _unreadCount + } + + fun resetUnreadCount(): Chat { + return copy(_unreadCount = 0) + } + + val isMuted: Boolean + get() { + return _isMuted + } + + fun setMuteState(muted: Boolean): Chat { + return copy(_isMuted = muted) + } + + val isSubscribed: Boolean + get() { + return _isSubscribed + } + + fun setSubscriptionState(subscribed: Boolean): Chat { + return copy(_isSubscribed = subscribed) + } + + val newestMessage: ChatMessage? + get() = messages.maxByOrNull { it.dateMillis } + + val lastMessageMillis: Long? + get() = newestMessage?.dateMillis +} \ No newline at end of file diff --git a/services/code/src/main/java/com/getcode/model/chat/Chats.kt b/services/code/src/main/java/com/getcode/model/chat/Chats.kt new file mode 100644 index 000000000..983a4b3eb --- /dev/null +++ b/services/code/src/main/java/com/getcode/model/chat/Chats.kt @@ -0,0 +1,7 @@ +package com.getcode.model.chat + +/** + * Code reference to a V1 [Chat] that serves as a collection of messages associated + * with a notification type (Tips, Cash Payments, Web Payments, etc.) + */ +typealias NotificationCollectionEntity = Chat diff --git a/services/code/src/main/java/com/getcode/model/extensions/AssociatedTokenAccount.kt b/services/code/src/main/java/com/getcode/model/extensions/AssociatedTokenAccount.kt new file mode 100644 index 000000000..25207b8c1 --- /dev/null +++ b/services/code/src/main/java/com/getcode/model/extensions/AssociatedTokenAccount.kt @@ -0,0 +1,15 @@ +package com.getcode.model.extensions + +import com.getcode.solana.keys.AssociatedTokenAccount +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey + +fun AssociatedTokenAccount.Companion.newInstance( + owner: PublicKey, + mint: Mint +): AssociatedTokenAccount { + return AssociatedTokenAccount( + owner = owner, + ata = PublicKey.deriveAssociatedAccount(owner = owner, mint = mint) + ) +} \ No newline at end of file diff --git a/services/code/src/main/java/com/getcode/model/extensions/ChatMessage.kt b/services/code/src/main/java/com/getcode/model/extensions/ChatMessage.kt new file mode 100644 index 000000000..20c67afea --- /dev/null +++ b/services/code/src/main/java/com/getcode/model/extensions/ChatMessage.kt @@ -0,0 +1,36 @@ +package com.getcode.model.extensions + +import com.getcode.ed25519.Ed25519 +import com.getcode.model.chat.ChatMessage +import com.getcode.model.chat.MessageContent + +fun ChatMessage.decryptingUsing(keyPair: Ed25519.KeyPair): ChatMessage { + return ChatMessage( + id = id, + senderId = senderId, + isFromSelf = isFromSelf, + dateMillis = dateMillis, + contents = contents.map { + when (it) { + is MessageContent.Exchange, + is MessageContent.Localized, + is MessageContent.Decrypted, + is MessageContent.RawText, + is MessageContent.Announcement -> it // passthrough + is MessageContent.SodiumBox -> { + val decrypted = it.data.decryptMessageUsingNaClBox(keyPair = keyPair) + if (decrypted != null) { + MessageContent.Decrypted(data = decrypted, isFromSelf = isFromSelf) + } else { + it + } + } + + is MessageContent.Reaction -> it + is MessageContent.Reply -> it + is MessageContent.DeletedMessage -> it + is MessageContent.Unknown -> it + } + } + ) +} \ No newline at end of file diff --git a/services/code/src/main/java/com/getcode/model/extensions/MemoProgram_Memo.kt b/services/code/src/main/java/com/getcode/model/extensions/MemoProgram_Memo.kt new file mode 100644 index 000000000..a05af2ca2 --- /dev/null +++ b/services/code/src/main/java/com/getcode/model/extensions/MemoProgram_Memo.kt @@ -0,0 +1,12 @@ +package com.getcode.model.extensions + +import com.getcode.model.SocialUser +import com.getcode.solana.instructions.programs.MemoProgram_Memo + +fun MemoProgram_Memo.Companion.newInstance(tipMetadata: SocialUser): MemoProgram_Memo { + val memo = "tip:${tipMetadata.platform}:${tipMetadata.username}" + + return MemoProgram_Memo( + memo.toByteArray().toList() + ) +} diff --git a/services/code/src/main/java/com/getcode/model/extensions/PreSwapStateAccount.kt b/services/code/src/main/java/com/getcode/model/extensions/PreSwapStateAccount.kt new file mode 100644 index 000000000..58768c104 --- /dev/null +++ b/services/code/src/main/java/com/getcode/model/extensions/PreSwapStateAccount.kt @@ -0,0 +1,16 @@ +package com.getcode.model.extensions + +import com.getcode.solana.keys.PreSwapStateAccount +import com.getcode.solana.keys.PublicKey + +fun PreSwapStateAccount.Companion.newInstance( + owner: PublicKey, + source: PublicKey, + destination: PublicKey, + nonce: PublicKey +): PreSwapStateAccount { + return PreSwapStateAccount( + owner = owner, + state = PublicKey.derivePreSwapState(source, destination, nonce) + ) +} \ No newline at end of file diff --git a/services/code/src/main/java/com/getcode/model/extensions/PublicKey.kt b/services/code/src/main/java/com/getcode/model/extensions/PublicKey.kt new file mode 100644 index 000000000..874f27dd2 --- /dev/null +++ b/services/code/src/main/java/com/getcode/model/extensions/PublicKey.kt @@ -0,0 +1,198 @@ +package com.getcode.model.extensions + +import com.getcode.crypt.Sha256Hash +import com.getcode.ed25519.Ed25519 +import com.getcode.model.Kin +import com.getcode.solana.instructions.programs.* +import com.getcode.solana.keys.Hash +import com.getcode.solana.keys.Key32.Companion.splitter +import com.getcode.solana.keys.Key32.Companion.subsidizer +import com.getcode.solana.keys.Key32.Companion.timeAuthority +import com.getcode.solana.keys.ProgramDerivedAccount +import com.getcode.solana.keys.PublicKey +import org.kin.sdk.base.tools.longToByteArray +import java.io.ByteArrayOutputStream +import java.io.IOException + +fun PublicKey.Companion.deriveAssociatedAccount(owner: PublicKey, mint: PublicKey): ProgramDerivedAccount { + return findProgramAddress( + seeds = listOf(owner.bytes.toByteArray(), TokenProgram.address.bytes.toByteArray(), mint.bytes.toByteArray()), + programId = AssociatedTokenProgram.address, + ) +} + +fun PublicKey.Companion.deriveTimelockStateAccount( + owner: PublicKey, + lockout: Long +): ProgramDerivedAccount { + val seeds: List = listOf( + "timelock_state".toByteArray(Charsets.UTF_8), + kin.bytes.toByteArray(), + timeAuthority.bytes.toByteArray(), + owner.bytes.toByteArray(), + byteArrayOf(lockout.toByte()) + ) + + return findProgramAddress( + seeds = seeds, + programId = TimelockProgram.address, + ) +} + +fun PublicKey.Companion.deriveTimelockVaultAccount( + stateAccount: PublicKey, + version: Long +): ProgramDerivedAccount { + val seeds: List = listOf( + "timelock_vault".toByteArray(Charsets.UTF_8), + stateAccount.bytes.toByteArray(), + byteArrayOf(version.toByte()) + ) + + return findProgramAddress( + seeds = seeds, + programId = TimelockProgram.address, + ) +} + +fun PublicKey.Companion.deriveLegacyTimelockStateAccount( + owner: PublicKey, + lockout: Long +): ProgramDerivedAccount { + val nonce = SystemProgram.address + val version = byteArrayOf(1) + val pdaPadding = SystemProgram.address + + val seeds: List = listOf( + "timelock_state".toByteArray(Charsets.UTF_8), + version, + kin.bytes.toByteArray(), + subsidizer.bytes.toByteArray(), + nonce.bytes.toByteArray(), + owner.bytes.toByteArray(), + lockout.longToByteArray(), + pdaPadding.bytes.toByteArray() + ) + + return findProgramAddress( + seeds = seeds, + programId = TimelockProgram.legacyAddress, + ) +} + +fun PublicKey.Companion.deriveLegacyTimelockVaultAccount( + stateAccount: PublicKey +): ProgramDerivedAccount { + val seeds: List = listOf( + "timelock_vault".toByteArray(Charsets.UTF_8), + stateAccount.bytes.toByteArray(), + byteArrayOf(0) + ) + + return findProgramAddress( + seeds = seeds, + programId = TimelockProgram.legacyAddress, + ) +} + +/// FindProgramAddress mirrors the implementation of the Solana SDK's FindProgramAddress. Its primary +/// use case (for Kin and Agora) is for deriving associated accounts. +/// +/// Reference: https://github.com/solana-labs/solana/blob/5548e599fe4920b71766e0ad1d121755ce9c63d5/sdk/program/src/pubkey.rs#L234 +/// +fun PublicKey.Companion.findProgramAddress( + seeds: List, + programId: PublicKey +): ProgramDerivedAccount { + for (i in 0..255) { + val bumpValue = 255 - i + try { + val publicKey = deriveProgramAddress(programId, listOf(*seeds.toTypedArray(), byteArrayOf(bumpValue.toByte()))) + return ProgramDerivedAccount(publicKey, bumpValue) + } catch (e: RuntimeException) { + //no-op + } + } + + throw Exception("Unable to find a viable program address nonce") +} + +/// CreateProgramAddress mirrors the implementation of the Solana SDK's CreateProgramAddress. +/// +/// ProgramAddresses are public keys that _do not_ lie on the ed25519 curve to ensure that +/// there is no associated private key. In the event that the program and seed parameters +/// result in a valid public key, ErrInvalidPublicKey is returned. +/// +/// Reference: https://github.com/solana-labs/solana/blob/5548e599fe4920b71766e0ad1d121755ce9c63d5/sdk/program/src/pubkey.rs#L158 +/// +fun PublicKey.Companion.deriveProgramAddress(programId: PublicKey, seeds: List): PublicKey { + fun PublicKey.Companion.getMaxSeeds() = 16 + + val buffer = ByteArrayOutputStream() + require(seeds.size < getMaxSeeds()) { "Max seed size exceeded" } + + for (seed in seeds) { + try { + buffer.write(seed) + } catch (e: IOException) { + throw RuntimeException(e) + } + } + try { + buffer.write(programId.bytes.toByteArray()) + buffer.write("ProgramDerivedAddress".toByteArray()) + } catch (e: IOException) { + throw RuntimeException(e) + } + val hash = Sha256Hash.hash(buffer.toByteArray()) + + val publicKey = PublicKey(hash.toList()) + + // Following the Solana SDK, we want to _reject_ the generated public key + // if it's a valid compressed EdwardsPoint (on the curve). + // + if (Ed25519.onCurve(publicKey.bytes.toByteArray())) { + throw RuntimeException("Invalid seeds, address must fall off the curve") + } + + return PublicKey(hash.toList()) +} + +fun PublicKey.Companion.deriveCommitmentStateAccount(treasury: PublicKey, recentRoot: Hash, transcript: Hash, destination: PublicKey, amount: Kin): ProgramDerivedAccount { + return findProgramAddress( + programId = splitter, + seeds = listOf( + "commitment_state".toByteArray(Charsets.UTF_8), + treasury.bytes.toByteArray(), + recentRoot.bytes.toByteArray(), + transcript.bytes.toByteArray(), + destination.bytes.toByteArray(), + amount.quarks.longToByteArray() + ) + ) +} + +fun PublicKey.Companion.deriveCommitmentVaultAccount(treasury: PublicKey, commitmentState: PublicKey): ProgramDerivedAccount { + return findProgramAddress( + programId = splitter, + seeds = listOf( + "commitment_vault".toByteArray(Charsets.UTF_8), + treasury.bytes.toByteArray(), + commitmentState.bytes.toByteArray() + ) + ) +} + +fun PublicKey.Companion.derivePreSwapState( + source: PublicKey, destination: PublicKey, nonce: PublicKey +): ProgramDerivedAccount { + return findProgramAddress( + programId = SwapValidatorProgram.address, + seeds = listOf( + "pre_swap_state".toByteArray(Charsets.UTF_8), + source.bytes.toByteArray(), + destination.bytes.toByteArray(), + nonce.bytes.toByteArray(), + ) + ) +} \ No newline at end of file diff --git a/services/code/src/main/java/com/getcode/model/extensions/SplitterCommitmentAccounts.kt b/services/code/src/main/java/com/getcode/model/extensions/SplitterCommitmentAccounts.kt new file mode 100644 index 000000000..3ce695768 --- /dev/null +++ b/services/code/src/main/java/com/getcode/model/extensions/SplitterCommitmentAccounts.kt @@ -0,0 +1,64 @@ +package com.getcode.model.extensions + +import com.getcode.model.Kin +import com.getcode.solana.keys.Hash +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.SplitterCommitmentAccounts +import com.getcode.solana.keys.SplitterTranscript +import com.getcode.solana.organizer.AccountCluster + +fun SplitterCommitmentAccounts.Companion.newInstance( + source: AccountCluster, + destination: PublicKey, + amount: Kin, + treasury: PublicKey, + recentRoot: Hash, + intentId: PublicKey, + actionId: Int +): SplitterCommitmentAccounts { + val transcript = SplitterTranscript( + intentId = intentId, + actionId = actionId, + amount = amount, + source = source.vaultPublicKey, + destination = destination + ) + + return newInstance( + treasury = treasury, + destination = destination, + recentRoot = recentRoot, + transcript = transcript.transcriptHash, + amount = amount + ) +} + +fun SplitterCommitmentAccounts.Companion.newInstance( + treasury: PublicKey, + destination: PublicKey, + recentRoot: Hash, + transcript: Hash, + amount: Kin +): SplitterCommitmentAccounts { + val state = PublicKey.deriveCommitmentStateAccount( + treasury = treasury, + recentRoot = recentRoot, + transcript = transcript, + destination = destination, + amount = amount + ) + + val vault = PublicKey.deriveCommitmentVaultAccount( + treasury = treasury, + commitmentState = state.publicKey + ) + + return SplitterCommitmentAccounts( + treasury = treasury, + destination = destination, + recentRoot = recentRoot, + transcript = transcript, + state = state, + vault = vault, + ) +} \ No newline at end of file diff --git a/services/code/src/main/java/com/getcode/model/extensions/TimelockDerivedAccounts.kt b/services/code/src/main/java/com/getcode/model/extensions/TimelockDerivedAccounts.kt new file mode 100644 index 000000000..08e09159f --- /dev/null +++ b/services/code/src/main/java/com/getcode/model/extensions/TimelockDerivedAccounts.kt @@ -0,0 +1,28 @@ +package com.getcode.model.extensions + +import com.getcode.solana.keys.ProgramDerivedAccount +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.TimelockDerivedAccounts + +fun TimelockDerivedAccounts.Companion.newInstance(owner: PublicKey, legacy: Boolean = false): TimelockDerivedAccounts { + val state: ProgramDerivedAccount + val vault: ProgramDerivedAccount + + if (legacy) { + state = + PublicKey.deriveLegacyTimelockStateAccount(owner = owner, lockout = 1_814_400) + vault = PublicKey.deriveLegacyTimelockVaultAccount(stateAccount = state.publicKey) + } else { + state = PublicKey.deriveTimelockStateAccount(owner = owner, lockout = lockoutInDays) + vault = PublicKey.deriveTimelockVaultAccount( + stateAccount = state.publicKey, + version = dataVersion + ) + } + + return TimelockDerivedAccounts( + owner = owner, + state = state, + vault = vault + ) +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/intents/IntentCreateAccounts.kt b/services/code/src/main/java/com/getcode/model/intents/IntentCreateAccounts.kt similarity index 97% rename from api/src/main/java/com/getcode/model/intents/IntentCreateAccounts.kt rename to services/code/src/main/java/com/getcode/model/intents/IntentCreateAccounts.kt index a1795e4b8..45b619867 100644 --- a/api/src/main/java/com/getcode/model/intents/IntentCreateAccounts.kt +++ b/services/code/src/main/java/com/getcode/model/intents/IntentCreateAccounts.kt @@ -5,7 +5,7 @@ import com.getcode.ed25519.Ed25519 import com.getcode.model.intents.actions.ActionOpenAccount import com.getcode.model.intents.actions.ActionType import com.getcode.model.intents.actions.ActionWithdraw -import com.getcode.network.repository.toPublicKey +import com.getcode.model.toPublicKey import com.getcode.solana.organizer.AccountType import com.getcode.solana.organizer.Organizer import com.getcode.solana.keys.PublicKey diff --git a/api/src/main/java/com/getcode/model/intents/IntentDeposit.kt b/services/code/src/main/java/com/getcode/model/intents/IntentDeposit.kt similarity index 99% rename from api/src/main/java/com/getcode/model/intents/IntentDeposit.kt rename to services/code/src/main/java/com/getcode/model/intents/IntentDeposit.kt index ddac3f9b7..1786f9ac3 100644 --- a/api/src/main/java/com/getcode/model/intents/IntentDeposit.kt +++ b/services/code/src/main/java/com/getcode/model/intents/IntentDeposit.kt @@ -2,6 +2,7 @@ package com.getcode.model.intents import com.codeinc.gen.transaction.v2.TransactionService import com.getcode.model.Kin +import com.getcode.model.generate import com.getcode.model.intents.actions.ActionTransfer import com.getcode.network.repository.toSolanaAccount import com.getcode.solana.keys.PublicKey diff --git a/api/src/main/java/com/getcode/model/intents/IntentEstablishRelationship.kt b/services/code/src/main/java/com/getcode/model/intents/IntentEstablishRelationship.kt similarity index 96% rename from api/src/main/java/com/getcode/model/intents/IntentEstablishRelationship.kt rename to services/code/src/main/java/com/getcode/model/intents/IntentEstablishRelationship.kt index 90afef395..5f6f4af5b 100644 --- a/api/src/main/java/com/getcode/model/intents/IntentEstablishRelationship.kt +++ b/services/code/src/main/java/com/getcode/model/intents/IntentEstablishRelationship.kt @@ -1,11 +1,11 @@ package com.getcode.model.intents -import android.content.Context import com.codeinc.gen.common.v1.Model import com.codeinc.gen.transaction.v2.TransactionService import com.getcode.model.Domain +import com.getcode.model.generate import com.getcode.model.intents.actions.ActionOpenAccount -import com.getcode.network.repository.toPublicKey +import com.getcode.model.toPublicKey import com.getcode.solana.keys.PublicKey import com.getcode.solana.organizer.AccountType import com.getcode.solana.organizer.Organizer diff --git a/api/src/main/java/com/getcode/model/intents/IntentMigratePrivacy.kt b/services/code/src/main/java/com/getcode/model/intents/IntentMigratePrivacy.kt similarity index 98% rename from api/src/main/java/com/getcode/model/intents/IntentMigratePrivacy.kt rename to services/code/src/main/java/com/getcode/model/intents/IntentMigratePrivacy.kt index f9a31bb4a..8b2107f9c 100644 --- a/api/src/main/java/com/getcode/model/intents/IntentMigratePrivacy.kt +++ b/services/code/src/main/java/com/getcode/model/intents/IntentMigratePrivacy.kt @@ -1,10 +1,10 @@ package com.getcode.model.intents -import android.content.Context import com.codeinc.gen.transaction.v2.TransactionService import com.getcode.crypt.DerivePath import com.getcode.crypt.DerivedKey import com.getcode.model.Kin +import com.getcode.model.generate import com.getcode.model.intents.actions.ActionCloseEmptyAccount import com.getcode.model.intents.actions.ActionWithdraw import com.getcode.solana.keys.PublicKey diff --git a/api/src/main/java/com/getcode/model/intents/IntentPrivateTransfer.kt b/services/code/src/main/java/com/getcode/model/intents/IntentPrivateTransfer.kt similarity index 97% rename from api/src/main/java/com/getcode/model/intents/IntentPrivateTransfer.kt rename to services/code/src/main/java/com/getcode/model/intents/IntentPrivateTransfer.kt index 938ae6c92..04876c5f3 100644 --- a/api/src/main/java/com/getcode/model/intents/IntentPrivateTransfer.kt +++ b/services/code/src/main/java/com/getcode/model/intents/IntentPrivateTransfer.kt @@ -1,25 +1,24 @@ package com.getcode.model.intents import com.codeinc.gen.chat.v2.ChatService +import com.codeinc.gen.common.v1.Model import com.codeinc.gen.transaction.v2.TransactionService import com.getcode.model.Fee -import com.getcode.model.ID import com.getcode.model.Kin import com.getcode.model.KinAmount import com.getcode.model.SocialUser -import com.getcode.model.chat.ChatIdV2 import com.getcode.model.chat.Platform import com.getcode.model.intents.actions.ActionFeePayment import com.getcode.model.intents.actions.ActionOpenAccount import com.getcode.model.intents.actions.ActionTransfer import com.getcode.model.intents.actions.ActionWithdraw -import com.getcode.network.repository.toByteString -import com.getcode.network.repository.toPublicKey +import com.getcode.model.toPublicKey import com.getcode.network.repository.toSolanaAccount import com.getcode.solana.keys.PublicKey import com.getcode.solana.organizer.AccountType import com.getcode.solana.organizer.Organizer import com.getcode.solana.organizer.Tray +import com.getcode.utils.toByteString import timber.log.Timber sealed interface PrivateTransferMetadata { @@ -60,7 +59,8 @@ class IntentPrivateTransfer( when (metadata) { is PrivateTransferMetadata.Chat -> { setIsChat(true) - setChatId(ChatIdV2.newBuilder() + setChatId( + Model.ChatId.newBuilder() .setValue(metadata.socialUser.chatId.toByteString()) ) } diff --git a/api/src/main/java/com/getcode/model/intents/IntentPublicTransfer.kt b/services/code/src/main/java/com/getcode/model/intents/IntentPublicTransfer.kt similarity index 97% rename from api/src/main/java/com/getcode/model/intents/IntentPublicTransfer.kt rename to services/code/src/main/java/com/getcode/model/intents/IntentPublicTransfer.kt index 3ac60d69d..c54f1b3e9 100644 --- a/api/src/main/java/com/getcode/model/intents/IntentPublicTransfer.kt +++ b/services/code/src/main/java/com/getcode/model/intents/IntentPublicTransfer.kt @@ -1,17 +1,15 @@ package com.getcode.model.intents -import android.util.Log import com.codeinc.gen.transaction.v2.TransactionService import com.getcode.model.KinAmount +import com.getcode.model.generate import com.getcode.model.intents.actions.ActionTransfer -import com.getcode.model.intents.actions.ActionWithdraw import com.getcode.network.repository.toSolanaAccount import com.getcode.solana.keys.* import com.getcode.solana.organizer.AccountCluster import com.getcode.solana.organizer.AccountType import com.getcode.solana.organizer.Organizer import com.getcode.solana.organizer.Tray -import timber.log.Timber class IntentPublicTransfer( override val id: PublicKey, diff --git a/api/src/main/java/com/getcode/model/intents/IntentReceive.kt b/services/code/src/main/java/com/getcode/model/intents/IntentReceive.kt similarity index 98% rename from api/src/main/java/com/getcode/model/intents/IntentReceive.kt rename to services/code/src/main/java/com/getcode/model/intents/IntentReceive.kt index 26aba317f..acb3cea1a 100644 --- a/api/src/main/java/com/getcode/model/intents/IntentReceive.kt +++ b/services/code/src/main/java/com/getcode/model/intents/IntentReceive.kt @@ -3,8 +3,9 @@ package com.getcode.model.intents import android.content.Context import com.codeinc.gen.transaction.v2.TransactionService import com.getcode.model.Kin +import com.getcode.model.generate import com.getcode.model.intents.actions.* -import com.getcode.network.repository.toPublicKey +import com.getcode.model.toPublicKey import com.getcode.network.repository.toSolanaAccount import com.getcode.solana.organizer.AccountType import com.getcode.solana.organizer.Organizer diff --git a/api/src/main/java/com/getcode/model/intents/IntentRemoteReceive.kt b/services/code/src/main/java/com/getcode/model/intents/IntentRemoteReceive.kt similarity index 98% rename from api/src/main/java/com/getcode/model/intents/IntentRemoteReceive.kt rename to services/code/src/main/java/com/getcode/model/intents/IntentRemoteReceive.kt index 0a999b4c7..7829751a1 100644 --- a/api/src/main/java/com/getcode/model/intents/IntentRemoteReceive.kt +++ b/services/code/src/main/java/com/getcode/model/intents/IntentRemoteReceive.kt @@ -3,6 +3,7 @@ package com.getcode.model.intents import android.content.Context import com.codeinc.gen.transaction.v2.TransactionService import com.getcode.model.Kin +import com.getcode.model.generate import com.getcode.model.intents.actions.ActionWithdraw import com.getcode.network.repository.toSolanaAccount import com.getcode.solana.keys.PublicKey diff --git a/api/src/main/java/com/getcode/model/intents/IntentRemoteSend.kt b/services/code/src/main/java/com/getcode/model/intents/IntentRemoteSend.kt similarity index 99% rename from api/src/main/java/com/getcode/model/intents/IntentRemoteSend.kt rename to services/code/src/main/java/com/getcode/model/intents/IntentRemoteSend.kt index 45e5d1c3e..e542a9c4f 100644 --- a/api/src/main/java/com/getcode/model/intents/IntentRemoteSend.kt +++ b/services/code/src/main/java/com/getcode/model/intents/IntentRemoteSend.kt @@ -6,7 +6,7 @@ import com.getcode.model.KinAmount import com.getcode.model.intents.actions.ActionOpenAccount import com.getcode.model.intents.actions.ActionTransfer import com.getcode.model.intents.actions.ActionWithdraw -import com.getcode.network.repository.toPublicKey +import com.getcode.model.toPublicKey import com.getcode.network.repository.toSolanaAccount import com.getcode.solana.keys.PublicKey import com.getcode.solana.organizer.AccountType diff --git a/api/src/main/java/com/getcode/model/intents/IntentType.kt b/services/code/src/main/java/com/getcode/model/intents/IntentType.kt similarity index 93% rename from api/src/main/java/com/getcode/model/intents/IntentType.kt rename to services/code/src/main/java/com/getcode/model/intents/IntentType.kt index 162c26dc0..586cf70bd 100644 --- a/api/src/main/java/com/getcode/model/intents/IntentType.kt +++ b/services/code/src/main/java/com/getcode/model/intents/IntentType.kt @@ -2,19 +2,16 @@ package com.getcode.model.intents import com.codeinc.gen.transaction.v2.TransactionService import com.getcode.ed25519.Ed25519 -import com.getcode.solana.keys.Signature import com.getcode.model.intents.actions.ActionType import com.getcode.model.intents.actions.numberActions import com.getcode.network.integrity.toDeviceToken import com.getcode.network.repository.* import com.getcode.solana.Message import com.getcode.solana.SolanaTransaction -import com.getcode.solana.builder.TransactionBuilder -import com.getcode.solana.keys.PublicKey import com.getcode.utils.sign abstract class IntentType { - abstract val id: PublicKey + abstract val id: com.getcode.solana.keys.PublicKey abstract val actionGroup: ActionGroup fun getActions() = actionGroup.actions @@ -41,7 +38,7 @@ abstract class IntentType { return SolanaTransaction(message, sigs) } - fun signatures(): List = + fun signatures(): List = actionGroup.actions.map { it.signatures().firstOrNull() }.mapNotNull { it } abstract fun metadata(): TransactionService.Metadata diff --git a/api/src/main/java/com/getcode/model/intents/IntentUpgradePrivacy.kt b/services/code/src/main/java/com/getcode/model/intents/IntentUpgradePrivacy.kt similarity index 98% rename from api/src/main/java/com/getcode/model/intents/IntentUpgradePrivacy.kt rename to services/code/src/main/java/com/getcode/model/intents/IntentUpgradePrivacy.kt index 0e93dce8b..9f7a37258 100644 --- a/api/src/main/java/com/getcode/model/intents/IntentUpgradePrivacy.kt +++ b/services/code/src/main/java/com/getcode/model/intents/IntentUpgradePrivacy.kt @@ -1,16 +1,15 @@ package com.getcode.model.intents -import android.content.Context import com.codeinc.gen.transaction.v2.TransactionService import com.getcode.crypt.MnemonicPhrase -import com.getcode.solana.keys.Hash -import com.getcode.solana.keys.Signature import com.getcode.model.Kin import com.getcode.model.UpgradeableIntent +import com.getcode.model.extensions.newInstance import com.getcode.model.intents.actions.ActionPrivacyUpgrade import com.getcode.model.intents.actions.ActionTransfer import com.getcode.solana.SolanaTransaction import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.Signature import com.getcode.solana.keys.SplitterCommitmentAccounts import com.getcode.solana.organizer.AccountCluster @@ -99,7 +98,7 @@ class IntentUpgradePrivacy( destination: PublicKey, originalNonce: PublicKey, treasury: PublicKey, - recentRoot: Hash + recentRoot: com.getcode.solana.keys.Hash ) { val transaction = SolanaTransaction.fromList(transactionData) ?: throw IntentUpgradePrivacyException.FailedToParseTransactionException() diff --git a/api/src/main/java/com/getcode/model/intents/ServerParameter.kt b/services/code/src/main/java/com/getcode/model/intents/ServerParameter.kt similarity index 73% rename from api/src/main/java/com/getcode/model/intents/ServerParameter.kt rename to services/code/src/main/java/com/getcode/model/intents/ServerParameter.kt index e7e7d340c..ef1ba45d5 100644 --- a/api/src/main/java/com/getcode/model/intents/ServerParameter.kt +++ b/services/code/src/main/java/com/getcode/model/intents/ServerParameter.kt @@ -2,8 +2,8 @@ package com.getcode.model.intents import com.codeinc.gen.transaction.v2.TransactionService import com.getcode.model.Kin -import com.getcode.network.repository.toHash -import com.getcode.network.repository.toPublicKey +import com.getcode.model.toHash +import com.getcode.model.toPublicKey import com.getcode.solana.keys.Hash import com.getcode.solana.keys.PublicKey @@ -32,22 +32,38 @@ class ServerParameter( return when (proto.typeCase) { TransactionService.ServerParameter.TypeCase.TEMPORARY_PRIVACY_TRANSFER -> { val param = proto.temporaryPrivacyTransfer - val treasury = PublicKey(param.treasury.value.toByteArray().toList()) - val recentRoot = Hash(param.recentRoot.value.toByteArray().toList()) + val treasury = PublicKey( + param.treasury.value.toByteArray().toList() + ) + val recentRoot = Hash( + param.recentRoot.value.toByteArray().toList() + ) return TempPrivacy(treasury, recentRoot) } TransactionService.ServerParameter.TypeCase.TEMPORARY_PRIVACY_EXCHANGE -> { val param = proto.temporaryPrivacyExchange - val treasury = PublicKey(param.treasury.value.toByteArray().toList()) - val recentRoot = Hash(param.recentRoot.value.toByteArray().toList()) + val treasury = PublicKey( + param.treasury.value.toByteArray().toList() + ) + val recentRoot = Hash( + param.recentRoot.value.toByteArray().toList() + ) return TempPrivacy(treasury, recentRoot) } TransactionService.ServerParameter.TypeCase.PERMANENT_PRIVACY_UPGRADE -> { val param = proto.permanentPrivacyUpgrade - val newCommitment = PublicKey(param.newCommitment.value.toByteArray().toList()) - val newCommitmentTranscript = Hash(param.newCommitmentTranscript.value.toByteArray().toList()) - val newCommitmentDestination = PublicKey(param.newCommitmentDestination.value.toByteArray().toList()) - val merkleRoot = Hash(param.merkleRoot.value.toByteArray().toList()) + val newCommitment = PublicKey( + param.newCommitment.value.toByteArray().toList() + ) + val newCommitmentTranscript = Hash( + param.newCommitmentTranscript.value.toByteArray().toList() + ) + val newCommitmentDestination = PublicKey( + param.newCommitmentDestination.value.toByteArray().toList() + ) + val merkleRoot = Hash( + param.merkleRoot.value.toByteArray().toList() + ) val merkleProof = param.merkleProofList.map { Hash(it.value.toByteArray().toList()) @@ -66,7 +82,9 @@ class ServerParameter( val param = proto.feePayment // PublicKey will be `nil` for .thirdParty fee payments - val optionalDestination = PublicKey(param.codeDestination.value.toByteArray().toList()) + val optionalDestination = PublicKey( + param.codeDestination.value.toByteArray().toList() + ) FeePayment(optionalDestination) } TransactionService.ServerParameter.TypeCase.OPEN_ACCOUNT, diff --git a/api/src/main/java/com/getcode/model/intents/SwapIntent.kt b/services/code/src/main/java/com/getcode/model/intents/SwapIntent.kt similarity index 95% rename from api/src/main/java/com/getcode/model/intents/SwapIntent.kt rename to services/code/src/main/java/com/getcode/model/intents/SwapIntent.kt index 2dd8a9805..f0501eee3 100644 --- a/api/src/main/java/com/getcode/model/intents/SwapIntent.kt +++ b/services/code/src/main/java/com/getcode/model/intents/SwapIntent.kt @@ -4,12 +4,13 @@ import com.codeinc.gen.common.v1.Model.InstructionAccount import com.codeinc.gen.transaction.v2.TransactionService.SwapRequest import com.codeinc.gen.transaction.v2.TransactionService.SwapResponse import com.getcode.ed25519.Ed25519.KeyPair -import com.getcode.network.repository.toHash -import com.getcode.network.repository.toPublicKey +import com.getcode.model.generate +import com.getcode.model.toHash +import com.getcode.model.toPublicKey import com.getcode.network.repository.toSignature -import com.getcode.solana.AccountMeta import com.getcode.solana.SolanaTransaction import com.getcode.solana.builder.TransactionBuilder +import com.getcode.solana.keys.AccountMeta import com.getcode.solana.keys.Hash import com.getcode.solana.keys.PublicKey import com.getcode.solana.keys.Signature @@ -17,9 +18,7 @@ import com.getcode.solana.organizer.AccountCluster import com.getcode.solana.organizer.AccountType import com.getcode.solana.organizer.Organizer import com.google.protobuf.ByteString -import org.kin.sdk.base.models.Key import java.lang.IllegalStateException -import kotlin.math.sign class SwapIntent( val id: PublicKey, diff --git a/api/src/main/java/com/getcode/model/intents/actions/ActionCloseEmptyAccount.kt b/services/code/src/main/java/com/getcode/model/intents/actions/ActionCloseEmptyAccount.kt similarity index 97% rename from api/src/main/java/com/getcode/model/intents/actions/ActionCloseEmptyAccount.kt rename to services/code/src/main/java/com/getcode/model/intents/actions/ActionCloseEmptyAccount.kt index 0d1d48d1b..f626ef3cc 100644 --- a/api/src/main/java/com/getcode/model/intents/actions/ActionCloseEmptyAccount.kt +++ b/services/code/src/main/java/com/getcode/model/intents/actions/ActionCloseEmptyAccount.kt @@ -9,7 +9,6 @@ import com.getcode.network.repository.toSolanaAccount import com.getcode.solana.SolanaTransaction import com.getcode.solana.builder.TransactionBuilder import com.getcode.solana.organizer.AccountType -import com.getcode.solana.keys.TimelockDerivedAccounts import com.getcode.solana.organizer.AccountCluster class ActionCloseEmptyAccount( diff --git a/api/src/main/java/com/getcode/model/intents/actions/ActionFeePayment.kt b/services/code/src/main/java/com/getcode/model/intents/actions/ActionFeePayment.kt similarity index 93% rename from api/src/main/java/com/getcode/model/intents/actions/ActionFeePayment.kt rename to services/code/src/main/java/com/getcode/model/intents/actions/ActionFeePayment.kt index 762feec0f..6d56fe75f 100644 --- a/api/src/main/java/com/getcode/model/intents/actions/ActionFeePayment.kt +++ b/services/code/src/main/java/com/getcode/model/intents/actions/ActionFeePayment.kt @@ -5,11 +5,9 @@ import com.codeinc.gen.transaction.v2.TransactionService.FeePaymentAction import com.getcode.ed25519.Ed25519 import com.getcode.model.Kin import com.getcode.model.intents.ServerParameter -import com.getcode.network.repository.toIntentId import com.getcode.network.repository.toSolanaAccount import com.getcode.solana.SolanaTransaction import com.getcode.solana.builder.TransactionBuilder -import com.getcode.solana.keys.PublicKey import com.getcode.solana.organizer.AccountCluster class ActionFeePayment( @@ -27,7 +25,7 @@ class ActionFeePayment( data object Code: Kind { override val codeType: Int = 0 } - data class ThirdParty(val destination: PublicKey): Kind { + data class ThirdParty(val destination: com.getcode.solana.keys.PublicKey): Kind { override val codeType: Int = 1 } } @@ -37,7 +35,7 @@ class ActionFeePayment( val timelock = cluster.timelock ?: return emptyList() - val destination: PublicKey = when (kind) { + val destination: com.getcode.solana.keys.PublicKey = when (kind) { Kind.Code -> { (serverParameter?.parameter as? ServerParameter.Parameter.FeePayment)?.publicKey ?: return emptyList() } diff --git a/api/src/main/java/com/getcode/model/intents/actions/ActionOpenAccount.kt b/services/code/src/main/java/com/getcode/model/intents/actions/ActionOpenAccount.kt similarity index 95% rename from api/src/main/java/com/getcode/model/intents/actions/ActionOpenAccount.kt rename to services/code/src/main/java/com/getcode/model/intents/actions/ActionOpenAccount.kt index 9e8062b4d..ce547935b 100644 --- a/api/src/main/java/com/getcode/model/intents/actions/ActionOpenAccount.kt +++ b/services/code/src/main/java/com/getcode/model/intents/actions/ActionOpenAccount.kt @@ -7,14 +7,13 @@ import com.getcode.network.repository.toSolanaAccount import com.getcode.solana.SolanaTransaction import com.getcode.solana.organizer.AccountCluster import com.getcode.solana.organizer.AccountType -import com.getcode.solana.keys.PublicKey import com.getcode.utils.sign class ActionOpenAccount( override var id: Int, override var serverParameter: ServerParameter? = null, override val signer: Ed25519.KeyPair?, - val owner: PublicKey, + val owner: com.getcode.solana.keys.PublicKey, val type: AccountType, val accountCluster: AccountCluster ) : ActionType() { @@ -48,7 +47,7 @@ class ActionOpenAccount( companion object { fun newInstance( - owner: PublicKey, + owner: com.getcode.solana.keys.PublicKey, type: AccountType, accountCluster: AccountCluster ): ActionOpenAccount { diff --git a/api/src/main/java/com/getcode/model/intents/actions/ActionPrivacyUpgrade.kt b/services/code/src/main/java/com/getcode/model/intents/actions/ActionPrivacyUpgrade.kt similarity index 86% rename from api/src/main/java/com/getcode/model/intents/actions/ActionPrivacyUpgrade.kt rename to services/code/src/main/java/com/getcode/model/intents/actions/ActionPrivacyUpgrade.kt index 4dd5a9835..8c972ecad 100644 --- a/api/src/main/java/com/getcode/model/intents/actions/ActionPrivacyUpgrade.kt +++ b/services/code/src/main/java/com/getcode/model/intents/actions/ActionPrivacyUpgrade.kt @@ -2,13 +2,11 @@ package com.getcode.model.intents.actions import com.codeinc.gen.transaction.v2.TransactionService import com.getcode.ed25519.Ed25519 -import com.getcode.solana.keys.Hash import com.getcode.model.Kin +import com.getcode.model.extensions.newInstance import com.getcode.model.intents.ServerParameter import com.getcode.solana.SolanaTransaction import com.getcode.solana.builder.TransactionBuilder -import com.getcode.solana.keys.PublicKey -import com.getcode.solana.keys.SplitterCommitmentAccounts import com.getcode.solana.keys.verifyContained import com.getcode.solana.organizer.AccountCluster import timber.log.Timber @@ -20,11 +18,11 @@ class ActionPrivacyUpgrade( var source: AccountCluster, var originalActionID: Int, - var originalCommitmentStateAccount: PublicKey, + var originalCommitmentStateAccount: com.getcode.solana.keys.PublicKey, var originalAmount: Kin, - var originalNonce: PublicKey, - var originalRecentBlockhash: Hash, - var treasury: PublicKey + var originalNonce: com.getcode.solana.keys.PublicKey, + var originalRecentBlockhash: com.getcode.solana.keys.Hash, + var treasury: com.getcode.solana.keys.PublicKey ) : ActionType() { val configCountRequirement: Int = 1 @@ -60,7 +58,7 @@ class ActionPrivacyUpgrade( // We'll user the original nonce and recentBlockhash that // the original transaction used. - val splitterAccounts = SplitterCommitmentAccounts.newInstance( + val splitterAccounts = com.getcode.solana.keys.SplitterCommitmentAccounts.newInstance( treasury = treasury, destination = privacyUpgrade.newCommitmentDestination, recentRoot = privacyUpgrade.merkleRoot, @@ -99,11 +97,11 @@ class ActionPrivacyUpgrade( fun newInstance( source: AccountCluster, originalActionID: Int, - originalCommitmentStateAccount: PublicKey, + originalCommitmentStateAccount: com.getcode.solana.keys.PublicKey, originalAmount: Kin, - originalNonce: PublicKey, - originalRecentBlockhash: Hash, - treasury: PublicKey + originalNonce: com.getcode.solana.keys.PublicKey, + originalRecentBlockhash: com.getcode.solana.keys.Hash, + treasury: com.getcode.solana.keys.PublicKey ): ActionPrivacyUpgrade { return ActionPrivacyUpgrade( id = 0, diff --git a/api/src/main/java/com/getcode/model/intents/actions/ActionTransfer.kt b/services/code/src/main/java/com/getcode/model/intents/actions/ActionTransfer.kt similarity index 90% rename from api/src/main/java/com/getcode/model/intents/actions/ActionTransfer.kt rename to services/code/src/main/java/com/getcode/model/intents/actions/ActionTransfer.kt index c9d6178d9..d0a575ecb 100644 --- a/api/src/main/java/com/getcode/model/intents/actions/ActionTransfer.kt +++ b/services/code/src/main/java/com/getcode/model/intents/actions/ActionTransfer.kt @@ -3,15 +3,13 @@ package com.getcode.model.intents.actions import com.codeinc.gen.transaction.v2.TransactionService import com.getcode.ed25519.Ed25519 import com.getcode.model.Kin +import com.getcode.model.extensions.newInstance import com.getcode.model.intents.ServerParameter import com.getcode.model.intents.actions.ActionTransfer.Kind.* import com.getcode.network.repository.toSolanaAccount import com.getcode.solana.SolanaTransaction import com.getcode.solana.builder.TransactionBuilder import com.getcode.solana.organizer.AccountCluster -import com.getcode.solana.keys.PublicKey -import com.getcode.solana.keys.SplitterCommitmentAccounts -import com.getcode.solana.keys.SplitterTranscript class ActionTransfer( override var id: Int, @@ -19,10 +17,10 @@ class ActionTransfer( override val signer: Ed25519.KeyPair? = null, val kind: Kind, - val intentId: PublicKey, + val intentId: com.getcode.solana.keys.PublicKey, val amount: Kin, val source: AccountCluster, - val destination: PublicKey, + val destination: com.getcode.solana.keys.PublicKey, ) : ActionType() { override fun transactions(): List { @@ -31,8 +29,8 @@ class ActionTransfer( val tempPrivacyParameter = serverParameter.parameter - val resolvedDestination: PublicKey = if (tempPrivacyParameter is ServerParameter.Parameter.TempPrivacy) { - val splitterAccounts = SplitterCommitmentAccounts.newInstance( + val resolvedDestination: com.getcode.solana.keys.PublicKey = if (tempPrivacyParameter is ServerParameter.Parameter.TempPrivacy) { + val splitterAccounts = com.getcode.solana.keys.SplitterCommitmentAccounts.newInstance( source = source, destination = destination, amount = amount, @@ -114,10 +112,10 @@ class ActionTransfer( companion object { fun newInstance( kind: Kind, - intentId: PublicKey, + intentId: com.getcode.solana.keys.PublicKey, amount: Kin, source: AccountCluster, - destination: PublicKey + destination: com.getcode.solana.keys.PublicKey ): ActionTransfer { return ActionTransfer( id = 0, diff --git a/api/src/main/java/com/getcode/model/intents/actions/ActionType.kt b/services/code/src/main/java/com/getcode/model/intents/actions/ActionType.kt similarity index 89% rename from api/src/main/java/com/getcode/model/intents/actions/ActionType.kt rename to services/code/src/main/java/com/getcode/model/intents/actions/ActionType.kt index 870d921a8..d6f233926 100644 --- a/api/src/main/java/com/getcode/model/intents/actions/ActionType.kt +++ b/services/code/src/main/java/com/getcode/model/intents/actions/ActionType.kt @@ -2,10 +2,8 @@ package com.getcode.model.intents.actions import com.codeinc.gen.transaction.v2.TransactionService import com.getcode.ed25519.Ed25519 -import com.getcode.solana.keys.Signature import com.getcode.model.intents.ServerParameter import com.getcode.solana.SolanaTransaction -import timber.log.Timber abstract class ActionType { abstract var id: Int @@ -16,7 +14,7 @@ abstract class ActionType { abstract fun transactions(): List - fun signatures(): List { + fun signatures(): List { return signer?.let { s -> transactions().map { transaction -> transaction.sign(s).first() } }.orEmpty() diff --git a/api/src/main/java/com/getcode/model/intents/actions/ActionWithdraw.kt b/services/code/src/main/java/com/getcode/model/intents/actions/ActionWithdraw.kt similarity index 95% rename from api/src/main/java/com/getcode/model/intents/actions/ActionWithdraw.kt rename to services/code/src/main/java/com/getcode/model/intents/actions/ActionWithdraw.kt index a3b05893f..6aedc5f89 100644 --- a/api/src/main/java/com/getcode/model/intents/actions/ActionWithdraw.kt +++ b/services/code/src/main/java/com/getcode/model/intents/actions/ActionWithdraw.kt @@ -2,17 +2,15 @@ package com.getcode.model.intents.actions import com.codeinc.gen.transaction.v2.TransactionService import com.getcode.ed25519.Ed25519 -import com.getcode.model.SocialUser import com.getcode.model.Kin import com.getcode.model.intents.PrivateTransferMetadata import com.getcode.model.intents.ServerParameter -import com.getcode.network.repository.toPublicKey +import com.getcode.model.toPublicKey import com.getcode.network.repository.toSolanaAccount import com.getcode.solana.SolanaTransaction import com.getcode.solana.builder.TransactionBuilder import com.getcode.solana.organizer.AccountCluster import com.getcode.solana.organizer.AccountType -import com.getcode.solana.keys.PublicKey class ActionWithdraw( override var id: Int, @@ -22,7 +20,7 @@ class ActionWithdraw( val kind: Kind, val cluster: AccountCluster, - val destination: PublicKey, + val destination: com.getcode.solana.keys.PublicKey, val legacy: Boolean, val metadata: PrivateTransferMetadata? = null, ) : ActionType() { @@ -86,7 +84,7 @@ class ActionWithdraw( fun newInstance( kind: Kind, cluster: AccountCluster, - destination: PublicKey, + destination: com.getcode.solana.keys.PublicKey, legacy: Boolean = false, metadata: PrivateTransferMetadata? = null, ): ActionWithdraw { diff --git a/api/src/main/java/com/getcode/model/notifications/CodeNotification.kt b/services/code/src/main/java/com/getcode/model/notifications/CodeNotification.kt similarity index 100% rename from api/src/main/java/com/getcode/model/notifications/CodeNotification.kt rename to services/code/src/main/java/com/getcode/model/notifications/CodeNotification.kt diff --git a/api/src/main/java/com/getcode/model/notifications/NotificationParser.kt b/services/code/src/main/java/com/getcode/model/notifications/NotificationParser.kt similarity index 94% rename from api/src/main/java/com/getcode/model/notifications/NotificationParser.kt rename to services/code/src/main/java/com/getcode/model/notifications/NotificationParser.kt index 3ee955e8e..470bf2fc3 100644 --- a/api/src/main/java/com/getcode/model/notifications/NotificationParser.kt +++ b/services/code/src/main/java/com/getcode/model/notifications/NotificationParser.kt @@ -1,9 +1,10 @@ package com.getcode.model.notifications -import com.codeinc.gen.chat.v2.ChatService +import com.codeinc.gen.chat.v1.ChatService import com.getcode.model.chat.MessageContent -import com.getcode.network.repository.decodeBase64 +import com.getcode.model.protomapping.invoke import com.getcode.utils.ErrorUtils +import com.getcode.utils.decodeBase64 import com.google.firebase.messaging.RemoteMessage import timber.log.Timber diff --git a/services/code/src/main/java/com/getcode/model/protomapping/ChatType.kt b/services/code/src/main/java/com/getcode/model/protomapping/ChatType.kt new file mode 100644 index 000000000..64938fd28 --- /dev/null +++ b/services/code/src/main/java/com/getcode/model/protomapping/ChatType.kt @@ -0,0 +1,8 @@ +package com.getcode.model.protomapping + +import com.codeinc.gen.chat.v2.ChatService +import com.getcode.model.chat.ChatType + +operator fun ChatType.Companion.invoke(proto: ChatService.ChatType): ChatType { + return runCatching { types[proto.ordinal] }.getOrNull() ?: ChatType.Unknown +} \ No newline at end of file diff --git a/services/code/src/main/java/com/getcode/model/protomapping/MessageContent.kt b/services/code/src/main/java/com/getcode/model/protomapping/MessageContent.kt new file mode 100644 index 000000000..d6df8b6fe --- /dev/null +++ b/services/code/src/main/java/com/getcode/model/protomapping/MessageContent.kt @@ -0,0 +1,89 @@ +package com.getcode.model.protomapping + +import com.codeinc.gen.chat.v1.ChatService +import com.getcode.model.EncryptedData +import com.getcode.model.Fiat +import com.getcode.model.GenericAmount +import com.getcode.model.ID +import com.getcode.model.Kin +import com.getcode.model.KinAmount +import com.getcode.model.Rate +import com.getcode.model.chat.MessageContent +import com.getcode.model.chat.Reference +import com.getcode.model.chat.Verb +import com.getcode.model.toPublicKey + +operator fun MessageContent.Companion.invoke( + proto: ChatService.Content, + messageId: ID? = null, +): MessageContent? { + return when (proto.typeCase) { + ChatService.Content.TypeCase.SERVER_LOCALIZED -> MessageContent.Localized( + isFromSelf = false, + value = proto.serverLocalized.keyOrText + ) + + ChatService.Content.TypeCase.EXCHANGE_DATA -> { + val verb = Verb.invoke(proto.exchangeData.verb) + val isFromSelf = !verb.increasesBalance + when (proto.exchangeData.exchangeDataCase) { + ChatService.ExchangeDataContent.ExchangeDataCase.EXACT -> { + val exact = proto.exchangeData.exact + val currency = + com.getcode.model.CurrencyCode.tryValueOf(exact.currency) ?: return null + val kinAmount = KinAmount.newInstance( + kin = Kin.fromQuarks(exact.quarks), + rate = Rate( + fx = exact.exchangeRate, + currency = currency + ) + ) + + MessageContent.Exchange( + isFromSelf = isFromSelf, + amount = GenericAmount.Exact(kinAmount), + verb = verb, + reference = messageId?.let { Reference.IntentId(it) }, + ) + } + + ChatService.ExchangeDataContent.ExchangeDataCase.PARTIAL -> { + val partial = proto.exchangeData.partial + val currency = + com.getcode.model.CurrencyCode.tryValueOf(partial.currency) ?: return null + + val fiat = Fiat( + currency = currency, + amount = partial.nativeAmount + ) + + MessageContent.Exchange( + isFromSelf = isFromSelf, + amount = GenericAmount.Partial(fiat), + verb = verb, + reference = messageId?.let { Reference.IntentId(it) }, + ) + } + + ChatService.ExchangeDataContent.ExchangeDataCase.EXCHANGEDATA_NOT_SET -> return null + else -> return null + } + } + + ChatService.Content.TypeCase.NACL_BOX -> { + val encryptedContent = proto.naclBox + val peerPublicKey = + encryptedContent.peerPublicKey.value.toByteArray().toPublicKey() + + val data = EncryptedData( + peerPublicKey = peerPublicKey, + nonce = encryptedContent.nonce.toByteArray().toList(), + encryptedData = encryptedContent.encryptedPayload.toByteArray().toList(), + ) + MessageContent.SodiumBox(isFromSelf = false, data = data) + } + + ChatService.Content.TypeCase.TYPE_NOT_SET -> return null + else -> return null + } +} \ No newline at end of file diff --git a/services/code/src/main/java/com/getcode/model/protomapping/Platform.kt b/services/code/src/main/java/com/getcode/model/protomapping/Platform.kt new file mode 100644 index 000000000..5fa7b3c80 --- /dev/null +++ b/services/code/src/main/java/com/getcode/model/protomapping/Platform.kt @@ -0,0 +1,10 @@ +package com.getcode.model.protomapping + +import com.codeinc.gen.chat.v2.ChatService +import com.getcode.model.chat.Platform +import com.getcode.model.chat.Platform.Unknown +import com.getcode.model.chat.Platform.entries + +operator fun Platform.Companion.invoke(proto: ChatService.Platform): Platform { + return runCatching { entries[proto.ordinal] }.getOrNull() ?: Unknown +} \ No newline at end of file diff --git a/services/code/src/main/java/com/getcode/model/protomapping/Pointer.kt b/services/code/src/main/java/com/getcode/model/protomapping/Pointer.kt new file mode 100644 index 000000000..d78cd17eb --- /dev/null +++ b/services/code/src/main/java/com/getcode/model/protomapping/Pointer.kt @@ -0,0 +1,19 @@ +package com.getcode.model.protomapping + +import com.codeinc.gen.chat.v2.ChatService +import com.getcode.model.chat.Pointer +import com.getcode.model.uuid + +operator fun Pointer.Companion.invoke(proto: ChatService.Pointer): Pointer { + val memberId = proto.memberId.value.toList() + val messageId = proto.value.value.toList().uuid ?: return Pointer.Unknown(memberId) + + return when (proto.type) { + ChatService.PointerType.UNKNOWN_POINTER_TYPE -> Pointer.Unknown(proto.memberId.value.toList()) + ChatService.PointerType.READ -> Pointer.Read(memberId, messageId) + ChatService.PointerType.DELIVERED -> Pointer.Delivered(memberId, messageId) + ChatService.PointerType.SENT -> Pointer.Sent(memberId, messageId) + ChatService.PointerType.UNRECOGNIZED -> Pointer.Unknown(memberId) + else -> Pointer.Unknown(memberId) + } +} \ No newline at end of file diff --git a/services/code/src/main/java/com/getcode/model/protomapping/Reference.kt b/services/code/src/main/java/com/getcode/model/protomapping/Reference.kt new file mode 100644 index 000000000..ccb8faefc --- /dev/null +++ b/services/code/src/main/java/com/getcode/model/protomapping/Reference.kt @@ -0,0 +1,17 @@ +package com.getcode.model.protomapping + +import com.codeinc.gen.chat.v2.ChatService.ExchangeDataContent +import com.codeinc.gen.chat.v2.ChatService.ExchangeDataContent.ReferenceCase +import com.getcode.model.chat.Reference +import com.getcode.model.chat.Reference.IntentId +import com.getcode.model.chat.Reference.NoneSet +import com.getcode.model.chat.Reference.Signature + +operator fun Reference.Companion.invoke(proto: ExchangeDataContent): Reference { + return when (proto.referenceCase) { + ReferenceCase.INTENT -> IntentId(proto.intent.value.toByteArray().toList()) + ReferenceCase.SIGNATURE -> Signature(proto.signature.value.toByteArray().toList()) + ReferenceCase.REFERENCE_NOT_SET -> NoneSet + null -> NoneSet + } +} \ No newline at end of file diff --git a/services/code/src/main/java/com/getcode/model/protomapping/TwitterUser.kt b/services/code/src/main/java/com/getcode/model/protomapping/TwitterUser.kt new file mode 100644 index 000000000..1f7d698c2 --- /dev/null +++ b/services/code/src/main/java/com/getcode/model/protomapping/TwitterUser.kt @@ -0,0 +1,30 @@ +package com.getcode.model.protomapping + +import com.codeinc.gen.user.v1.IdentityService +import com.codeinc.gen.user.v1.friendshipCostOrNull +import com.getcode.model.CurrencyCode +import com.getcode.model.Fiat +import com.getcode.model.TwitterUser +import com.getcode.model.TwitterUser.VerificationStatus +import com.getcode.solana.keys.PublicKey + +operator fun TwitterUser.Companion.invoke(proto: IdentityService.TwitterUser): TwitterUser? { + val avatarUrl = proto.profilePicUrl + + val tipAddress = runCatching { PublicKey.fromByteString(proto.tipAddress.value) }.getOrNull() ?: return null + + return TwitterUser( + username = proto.username, + displayName = proto.name, + imageUrl = avatarUrl, + followerCount = proto.followerCount, + tipAddress = tipAddress, + verificationStatus = VerificationStatus.entries.getOrNull(proto.verifiedTypeValue) ?: VerificationStatus.unknown, + costOfFriendship = proto.friendshipCostOrNull?.let { + val currency = CurrencyCode.tryValueOf(it.currency) ?: return@let null + Fiat(currency, it.nativeAmount) + } ?: Fiat(currency = CurrencyCode.USD, amount = 1.00), + isFriend = runCatching { proto.isFriend }.getOrNull() ?: false, + chatId = proto.friendChatId.value.toList() + ) +} \ No newline at end of file diff --git a/services/code/src/main/java/com/getcode/model/protomapping/Verb.kt b/services/code/src/main/java/com/getcode/model/protomapping/Verb.kt new file mode 100644 index 000000000..9200b2dd0 --- /dev/null +++ b/services/code/src/main/java/com/getcode/model/protomapping/Verb.kt @@ -0,0 +1,34 @@ +package com.getcode.model.protomapping + +import com.codeinc.gen.chat.v1.ChatService +import com.getcode.model.chat.Verb +import com.getcode.model.chat.Verb.Deposited +import com.getcode.model.chat.Verb.Gave +import com.getcode.model.chat.Verb.Paid +import com.getcode.model.chat.Verb.Purchased +import com.getcode.model.chat.Verb.Received +import com.getcode.model.chat.Verb.ReceivedTip +import com.getcode.model.chat.Verb.Returned +import com.getcode.model.chat.Verb.Sent +import com.getcode.model.chat.Verb.SentTip +import com.getcode.model.chat.Verb.Spent +import com.getcode.model.chat.Verb.Unknown +import com.getcode.model.chat.Verb.Withdrew + +fun Verb.Companion.invoke(proto: ChatService.ExchangeDataContent.Verb): Verb { + return when (proto) { + com.codeinc.gen.chat.v1.ChatService.ExchangeDataContent.Verb.UNKNOWN -> Unknown + com.codeinc.gen.chat.v1.ChatService.ExchangeDataContent.Verb.GAVE -> Gave + com.codeinc.gen.chat.v1.ChatService.ExchangeDataContent.Verb.RECEIVED -> Received + com.codeinc.gen.chat.v1.ChatService.ExchangeDataContent.Verb.WITHDREW -> Withdrew + com.codeinc.gen.chat.v1.ChatService.ExchangeDataContent.Verb.DEPOSITED -> Deposited + com.codeinc.gen.chat.v1.ChatService.ExchangeDataContent.Verb.SENT -> Sent + com.codeinc.gen.chat.v1.ChatService.ExchangeDataContent.Verb.RETURNED -> Returned + com.codeinc.gen.chat.v1.ChatService.ExchangeDataContent.Verb.SPENT -> Spent + com.codeinc.gen.chat.v1.ChatService.ExchangeDataContent.Verb.PAID -> Paid + com.codeinc.gen.chat.v1.ChatService.ExchangeDataContent.Verb.PURCHASED -> Purchased + com.codeinc.gen.chat.v1.ChatService.ExchangeDataContent.Verb.UNRECOGNIZED -> Unknown + com.codeinc.gen.chat.v1.ChatService.ExchangeDataContent.Verb.RECEIVED_TIP -> ReceivedTip + com.codeinc.gen.chat.v1.ChatService.ExchangeDataContent.Verb.SENT_TIP -> SentTip + } +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/BalanceController.kt b/services/code/src/main/java/com/getcode/network/BalanceController.kt similarity index 96% rename from api/src/main/java/com/getcode/network/BalanceController.kt rename to services/code/src/main/java/com/getcode/network/BalanceController.kt index 41015455b..41df32b1f 100644 --- a/api/src/main/java/com/getcode/network/BalanceController.kt +++ b/services/code/src/main/java/com/getcode/network/BalanceController.kt @@ -12,8 +12,6 @@ import com.getcode.network.repository.TransactionRepository import com.getcode.solana.organizer.Organizer import com.getcode.solana.organizer.Tray import com.getcode.utils.FormatUtils -import com.getcode.utils.network.NetworkConnectivityListener -import com.getcode.utils.network.retryable import com.getcode.utils.trace import io.reactivex.rxjava3.core.Completable import kotlinx.coroutines.CoroutineScope @@ -44,7 +42,7 @@ data class BalanceDisplay( open class BalanceController @Inject constructor( exchange: Exchange, - networkObserver: NetworkConnectivityListener, + networkObserver: com.getcode.utils.network.NetworkConnectivityListener, private val balanceRepository: BalanceRepository, private val transactionRepository: TransactionRepository, private val accountRepository: AccountRepository, @@ -70,7 +68,7 @@ open class BalanceController @Inject constructor( .map { it.connected } .onEach { connected -> if (connected) { - retryable({ fetchBalanceSuspend() }) + com.getcode.utils.network.retryable { fetchBalanceSuspend() } } } .flatMapLatest { @@ -233,10 +231,10 @@ open class BalanceController @Inject constructor( } private fun isKin(selectedCurrency: Currency): Boolean = - selectedCurrency.code == CurrencyCode.KIN.name + selectedCurrency.code == com.getcode.model.CurrencyCode.KIN.name private fun formatAmount(amount: Double, currency: Currency?): String { - return if (amount % 1 == 0.0 || currency?.code == CurrencyCode.KIN.name) { + return if (amount % 1 == 0.0 || currency?.code == com.getcode.model.CurrencyCode.KIN.name) { String.format(Locale.getDefault(), "%,.0f", amount) } else { String.format(Locale.getDefault(), "%,.2f", amount) diff --git a/services/code/src/main/java/com/getcode/network/IdentityManager.kt b/services/code/src/main/java/com/getcode/network/IdentityManager.kt new file mode 100644 index 000000000..a1d4db62e --- /dev/null +++ b/services/code/src/main/java/com/getcode/network/IdentityManager.kt @@ -0,0 +1,31 @@ +package com.getcode.network + +import com.getcode.manager.SessionManager +import com.getcode.utils.base58 +import com.getcode.utils.bytes +import com.getcode.vendor.Base58 +import java.util.UUID +import javax.inject.Inject + +class IdentityManager @Inject constructor() { + fun generateVerificationTweet(accountName: String): String? { + val authority = SessionManager.getOrganizer()?.tray?.owner?.getCluster()?.authority + val tipAddress = SessionManager.getOrganizer()?.primaryVault + ?.let { Base58.encode(it.byteArray) } + + if (tipAddress != null && authority != null) { + val nonce = UUID.randomUUID() + val signature = authority.keyPair.sign(nonce.bytes.toByteArray()) + val verificationMessage = listOf( + accountName, + tipAddress, + Base58.encode(nonce.bytes.toByteArray()), + signature.base58 + ).joinToString(":") + + return verificationMessage + } + + return null + } +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/NotificationCollectionHistoryController.kt b/services/code/src/main/java/com/getcode/network/NotificationCollectionHistoryController.kt similarity index 95% rename from api/src/main/java/com/getcode/network/NotificationCollectionHistoryController.kt rename to services/code/src/main/java/com/getcode/network/NotificationCollectionHistoryController.kt index a6a9743e5..c08fca3eb 100644 --- a/api/src/main/java/com/getcode/network/NotificationCollectionHistoryController.kt +++ b/services/code/src/main/java/com/getcode/network/NotificationCollectionHistoryController.kt @@ -10,21 +10,20 @@ import com.getcode.manager.SessionManager import com.getcode.model.chat.ChatMessage import com.getcode.model.Cursor import com.getcode.model.ID -import com.getcode.model.MessageStatus +import com.getcode.model.chat.MessageStatus import com.getcode.model.chat.NotificationCollectionEntity import com.getcode.model.chat.Title -import com.getcode.model.chat.isNotification import com.getcode.network.client.Client import com.getcode.network.client.advancePointer +import com.getcode.network.client.fetchChats import com.getcode.network.client.fetchMessagesFor -import com.getcode.network.client.fetchV1Chats import com.getcode.network.client.setMuted import com.getcode.network.client.setSubscriptionState -import com.getcode.network.repository.encodeBase64 import com.getcode.network.source.CollectionPagingSource import com.getcode.util.resources.ResourceHelper import com.getcode.util.resources.ResourceType import com.getcode.utils.TraceType +import com.getcode.utils.encodeBase64 import com.getcode.utils.trace import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -51,7 +50,6 @@ class NotificationCollectionHistoryController @Inject constructor( val notifications: StateFlow?> get() = collectionEntries - .map { it?.filter { entry -> entry.isNotification } } .stateIn(this, SharingStarted.Eagerly, emptyList()) var loadingCollections: Boolean = false @@ -111,7 +109,10 @@ class NotificationCollectionHistoryController @Inject constructor( val updatedWithMessages = mutableListOf() val containers = fetchCollectionsWithoutMessages() - trace(message = "Fetched ${containers.count()} collections", type = TraceType.Silent) + trace( + message = "Fetched ${containers.count()} collections", + type = TraceType.Silent + ) if (!update) { pagerMap.clear() @@ -209,7 +210,7 @@ class NotificationCollectionHistoryController @Inject constructor( private suspend fun fetchCollectionsWithoutMessages(): List { val owner = owner() ?: return emptyList() - val result = client.fetchV1Chats(owner) + val result = client.fetchChats(owner) return result.getOrNull().orEmpty() } } diff --git a/api/src/main/java/com/getcode/network/PrivacyMigration.kt b/services/code/src/main/java/com/getcode/network/PrivacyMigration.kt similarity index 97% rename from api/src/main/java/com/getcode/network/PrivacyMigration.kt rename to services/code/src/main/java/com/getcode/network/PrivacyMigration.kt index 2380b58ef..2b7d64ea3 100644 --- a/api/src/main/java/com/getcode/network/PrivacyMigration.kt +++ b/services/code/src/main/java/com/getcode/network/PrivacyMigration.kt @@ -1,6 +1,5 @@ package com.getcode.network -import android.content.Context import com.getcode.analytics.AnalyticsService import com.getcode.model.Kin import com.getcode.model.intents.IntentType diff --git a/api/src/main/java/com/getcode/network/TipController.kt b/services/code/src/main/java/com/getcode/network/TipController.kt similarity index 85% rename from api/src/main/java/com/getcode/network/TipController.kt rename to services/code/src/main/java/com/getcode/network/TipController.kt index eb4d2c7ae..8e0c3c0d6 100644 --- a/api/src/main/java/com/getcode/network/TipController.kt +++ b/services/code/src/main/java/com/getcode/network/TipController.kt @@ -1,9 +1,9 @@ package com.getcode.network import com.getcode.manager.SessionManager -import com.getcode.model.CodePayload -import com.getcode.model.PrefsBool -import com.getcode.model.PrefsString +import com.getcode.services.model.CodePayload +import com.getcode.services.model.PrefsBool +import com.getcode.services.model.PrefsString import com.getcode.model.SocialUser import com.getcode.model.TwitterUser import com.getcode.network.client.Client @@ -11,10 +11,7 @@ import com.getcode.network.client.fetchTwitterUser import com.getcode.network.repository.BetaFlagsRepository import com.getcode.network.repository.PrefRepository import com.getcode.network.repository.TwitterUserFetchError -import com.getcode.network.repository.base58 -import com.getcode.utils.bytes -import com.getcode.utils.getOrPutIfNonNull -import com.getcode.vendor.Base58 +import com.getcode.services.utils.getOrPutIfNonNull import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -32,7 +29,6 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import timber.log.Timber import java.util.Timer -import java.util.UUID import javax.inject.Inject import javax.inject.Singleton import kotlin.concurrent.fixedRateTimer @@ -180,27 +176,6 @@ class TipController @Inject constructor( prefRepository.set(PrefsBool.SEEN_TIP_CARD, true) } - fun generateTipVerification(): String? { - val authority = SessionManager.getOrganizer()?.tray?.owner?.getCluster()?.authority - val tipAddress = SessionManager.getOrganizer()?.primaryVault - ?.let { Base58.encode(it.byteArray) } - - if (tipAddress != null && authority != null) { - val nonce = UUID.randomUUID() - val signature = authority.keyPair.sign(nonce.bytes.toByteArray()) - val verificationMessage = listOf( - "CodeAccount", - tipAddress, - Base58.encode(nonce.bytes.toByteArray()), - signature.base58 - ).joinToString(":") - - return verificationMessage - } - - return null - } - fun startVerification() { prefRepository.set(PrefsBool.STARTED_TIP_CONNECT, true) } diff --git a/api/src/main/java/com/getcode/network/TwitterUserController.kt b/services/code/src/main/java/com/getcode/network/TwitterUserController.kt similarity index 95% rename from api/src/main/java/com/getcode/network/TwitterUserController.kt rename to services/code/src/main/java/com/getcode/network/TwitterUserController.kt index 14e9dba86..079c57a2f 100644 --- a/api/src/main/java/com/getcode/network/TwitterUserController.kt +++ b/services/code/src/main/java/com/getcode/network/TwitterUserController.kt @@ -6,7 +6,7 @@ import com.getcode.network.client.Client import com.getcode.network.client.fetchTwitterUser import com.getcode.network.repository.BetaFlagsRepository import com.getcode.network.repository.PrefRepository -import com.getcode.utils.getOrPutIfNonNull +import com.getcode.services.utils.getOrPutIfNonNull import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton diff --git a/api/src/main/java/com/getcode/network/api/AccountApi.kt b/services/code/src/main/java/com/getcode/network/api/AccountApi.kt similarity index 94% rename from api/src/main/java/com/getcode/network/api/AccountApi.kt rename to services/code/src/main/java/com/getcode/network/api/AccountApi.kt index cbd50513b..f7dcbdc77 100644 --- a/api/src/main/java/com/getcode/network/api/AccountApi.kt +++ b/services/code/src/main/java/com/getcode/network/api/AccountApi.kt @@ -4,9 +4,10 @@ import com.codeinc.gen.account.v1.AccountGrpc import com.codeinc.gen.account.v1.AccountService import com.codeinc.gen.account.v1.AccountService.LinkAdditionalAccountsRequest import com.codeinc.gen.account.v1.AccountService.LinkAdditionalAccountsResponse +import com.getcode.annotations.CodeManagedChannel import com.getcode.ed25519.Ed25519.KeyPair -import com.getcode.network.core.GrpcApi import com.getcode.network.repository.toSolanaAccount +import com.getcode.services.network.core.GrpcApi import com.getcode.utils.sign import io.grpc.ManagedChannel import io.reactivex.rxjava3.core.Scheduler @@ -19,6 +20,7 @@ import javax.inject.Inject class AccountApi @Inject constructor( + @CodeManagedChannel managedChannel: ManagedChannel, private val scheduler: Scheduler = Schedulers.io(), ) : GrpcApi(managedChannel) { diff --git a/api/src/main/java/com/getcode/network/api/ChatApiV1.kt b/services/code/src/main/java/com/getcode/network/api/ChatApi.kt similarity index 61% rename from api/src/main/java/com/getcode/network/api/ChatApiV1.kt rename to services/code/src/main/java/com/getcode/network/api/ChatApi.kt index f5762d32f..74598272e 100644 --- a/api/src/main/java/com/getcode/network/api/ChatApiV1.kt +++ b/services/code/src/main/java/com/getcode/network/api/ChatApi.kt @@ -1,42 +1,29 @@ package com.getcode.network.api +import com.codeinc.gen.chat.v1.ChatGrpc import com.codeinc.gen.chat.v1.ChatService +import com.getcode.annotations.CodeManagedChannel import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.model.Cursor import com.getcode.model.ID -import com.getcode.network.core.GrpcApi -import com.getcode.network.repository.toByteString import com.getcode.network.repository.toSolanaAccount +import com.getcode.services.network.core.GrpcApi import com.getcode.utils.sign +import com.getcode.utils.toByteString import io.grpc.ManagedChannel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOn import javax.inject.Inject -import com.getcode.model.chat.AdvancePointerRequestV1 as AdvancePointerRequest -import com.getcode.model.chat.AdvancePointerResponseV1 as AdvancePointerResponse -import com.getcode.model.chat.ChatCursorV1 as ChatCursor -import com.getcode.model.chat.ChatGrpcV1 as ChatGrpc -import com.getcode.model.chat.ChatIdV1 as ChatId -import com.getcode.model.chat.GetChatsRequestV1 as GetChatsRequest -import com.getcode.model.chat.GetChatsResponseV1 as GetChatsResponse -import com.getcode.model.chat.GetMessagesDirectionV1 as GetMessagesDirection -import com.getcode.model.chat.GetMessagesRequestV1 as GetMessagesRequest -import com.getcode.model.chat.GetMessagesResponseV1 as GetMessagesResponse -import com.getcode.model.chat.PointerV1 as Pointer -import com.getcode.model.chat.SetMuteStateRequestV1 as SetMuteStateRequest -import com.getcode.model.chat.SetMuteStateResponseV1 as SetMuteStateResponse -import com.getcode.model.chat.SetSubscriptionStateRequestV1 as SetSubscriptionStateRequest -import com.getcode.model.chat.SetSubscriptionStateResponseV1 as SetSubscriptionStateResponse - -@Deprecated("Replaced with V2") -class ChatApiV1 @Inject constructor( + +class ChatApi @Inject constructor( + @CodeManagedChannel managedChannel: ManagedChannel ) : GrpcApi(managedChannel) { private val api = ChatGrpc.newStub(managedChannel).withWaitForReady() - fun fetchChats(owner: KeyPair): Flow { - val request = GetChatsRequest.newBuilder() + fun fetchChats(owner: KeyPair): Flow { + val request = ChatService.GetChatsRequest.newBuilder() .setOwner(owner.publicKeyBytes.toSolanaAccount()) .apply { setSignature(sign(owner)) } .build() @@ -51,17 +38,17 @@ class ChatApiV1 @Inject constructor( chatId: ID, cursor: Cursor? = null, limit: Int? = null - ): Flow { - val builder = GetMessagesRequest.newBuilder() + ): Flow { + val builder = ChatService.GetMessagesRequest.newBuilder() .setChatId( - ChatId.newBuilder() + ChatService.ChatId.newBuilder() .setValue(chatId.toByteArray().toByteString()) .build() ) if (cursor != null) { builder.setCursor( - ChatCursor.newBuilder() + ChatService.Cursor.newBuilder() .setValue(cursor.toByteString()) ) } @@ -70,7 +57,7 @@ class ChatApiV1 @Inject constructor( builder.setPageSize(limit) } - builder.setDirection(GetMessagesDirection.DESC) + builder.setDirection(ChatService.GetMessagesRequest.Direction.DESC) val request = builder .setOwner(owner.publicKeyBytes.toSolanaAccount()) @@ -82,14 +69,14 @@ class ChatApiV1 @Inject constructor( .flowOn(Dispatchers.IO) } - fun advancePointer(owner: KeyPair, chatId: ID, to: ID, kind: ChatService.Pointer.Kind): Flow { - val request = AdvancePointerRequest.newBuilder() + fun advancePointer(owner: KeyPair, chatId: ID, to: ID, kind: ChatService.Pointer.Kind): Flow { + val request = ChatService.AdvancePointerRequest.newBuilder() .setChatId( - ChatId.newBuilder() + ChatService.ChatId.newBuilder() .setValue(chatId.toByteArray().toByteString()) .build() ).setPointer( - Pointer.newBuilder() + ChatService.Pointer.newBuilder() .setKind(kind) .setValue( ChatService.ChatMessageId.newBuilder() @@ -104,10 +91,10 @@ class ChatApiV1 @Inject constructor( .flowOn(Dispatchers.IO) } - fun setMuteState(owner: KeyPair, chatId: ID, muted: Boolean): Flow { - val request = SetMuteStateRequest.newBuilder() + fun setMuteState(owner: KeyPair, chatId: ID, muted: Boolean): Flow { + val request = ChatService.SetMuteStateRequest.newBuilder() .setChatId( - ChatId.newBuilder() + ChatService.ChatId.newBuilder() .setValue(chatId.toByteArray().toByteString()) .build() ).setIsMuted(muted) @@ -124,10 +111,10 @@ class ChatApiV1 @Inject constructor( owner: KeyPair, chatId: ID, subscribed: Boolean - ): Flow { - val request = SetSubscriptionStateRequest.newBuilder() + ): Flow { + val request = ChatService.SetSubscriptionStateRequest.newBuilder() .setChatId( - ChatId.newBuilder() + ChatService.ChatId.newBuilder() .setValue(chatId.toByteArray().toByteString()) .build() ).setIsSubscribed(subscribed) diff --git a/api/src/main/java/com/getcode/network/api/CurrencyApi.kt b/services/code/src/main/java/com/getcode/network/api/CurrencyApi.kt similarity index 81% rename from api/src/main/java/com/getcode/network/api/CurrencyApi.kt rename to services/code/src/main/java/com/getcode/network/api/CurrencyApi.kt index af60bbdb6..3168f7c93 100644 --- a/api/src/main/java/com/getcode/network/api/CurrencyApi.kt +++ b/services/code/src/main/java/com/getcode/network/api/CurrencyApi.kt @@ -2,17 +2,16 @@ package com.getcode.network.api import com.codeinc.gen.currency.v1.CurrencyGrpc import com.codeinc.gen.currency.v1.CurrencyService -import com.getcode.network.core.GrpcApi +import com.getcode.annotations.CodeManagedChannel +import com.getcode.services.network.core.GrpcApi import io.grpc.ManagedChannel -import io.reactivex.rxjava3.core.Scheduler -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOn import javax.inject.Inject class CurrencyApi @Inject constructor( + @CodeManagedChannel managedChannel: ManagedChannel, ) : GrpcApi(managedChannel) { private val api = CurrencyGrpc.newStub(managedChannel).withWaitForReady() diff --git a/api/src/main/java/com/getcode/network/api/DeviceApi.kt b/services/code/src/main/java/com/getcode/network/api/DeviceApi.kt similarity index 93% rename from api/src/main/java/com/getcode/network/api/DeviceApi.kt rename to services/code/src/main/java/com/getcode/network/api/DeviceApi.kt index a0b4a3e16..f9ce8f886 100644 --- a/api/src/main/java/com/getcode/network/api/DeviceApi.kt +++ b/services/code/src/main/java/com/getcode/network/api/DeviceApi.kt @@ -3,10 +3,11 @@ package com.getcode.network.api import com.codeinc.gen.common.v1.Model import com.codeinc.gen.device.v1.DeviceGrpc import com.codeinc.gen.device.v1.DeviceService +import com.getcode.annotations.CodeManagedChannel import com.getcode.ed25519.Ed25519.KeyPair -import com.getcode.network.core.GrpcApi import com.getcode.network.repository.sign import com.getcode.network.repository.toSolanaAccount +import com.getcode.services.network.core.GrpcApi import io.grpc.ManagedChannel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -14,6 +15,7 @@ import kotlinx.coroutines.flow.flowOn import javax.inject.Inject class DeviceApi @Inject constructor( + @CodeManagedChannel managedChannel: ManagedChannel, ): GrpcApi(managedChannel) { diff --git a/api/src/main/java/com/getcode/network/api/IdentityApi.kt b/services/code/src/main/java/com/getcode/network/api/IdentityApi.kt similarity index 94% rename from api/src/main/java/com/getcode/network/api/IdentityApi.kt rename to services/code/src/main/java/com/getcode/network/api/IdentityApi.kt index 8245f2458..daf91bfcc 100644 --- a/api/src/main/java/com/getcode/network/api/IdentityApi.kt +++ b/services/code/src/main/java/com/getcode/network/api/IdentityApi.kt @@ -5,7 +5,8 @@ import com.codeinc.gen.user.v1.IdentityService import com.codeinc.gen.user.v1.IdentityService.GetTwitterUserRequest import com.codeinc.gen.user.v1.IdentityService.LoginToThirdPartyAppRequest import com.codeinc.gen.user.v1.IdentityService.UpdatePreferencesRequest -import com.getcode.network.core.GrpcApi +import com.getcode.annotations.CodeManagedChannel +import com.getcode.services.network.core.GrpcApi import io.grpc.ManagedChannel import io.reactivex.rxjava3.annotations.NonNull import io.reactivex.rxjava3.core.Scheduler @@ -16,6 +17,7 @@ import kotlinx.coroutines.flow.flowOn import javax.inject.Inject class IdentityApi @Inject constructor( + @CodeManagedChannel managedChannel: ManagedChannel, private val scheduler: Scheduler = Schedulers.io(), ) : GrpcApi(managedChannel) { diff --git a/api/src/main/java/com/getcode/network/api/MessagingApi.kt b/services/code/src/main/java/com/getcode/network/api/MessagingApi.kt similarity index 93% rename from api/src/main/java/com/getcode/network/api/MessagingApi.kt rename to services/code/src/main/java/com/getcode/network/api/MessagingApi.kt index d1a825bd3..9f979d83a 100644 --- a/api/src/main/java/com/getcode/network/api/MessagingApi.kt +++ b/services/code/src/main/java/com/getcode/network/api/MessagingApi.kt @@ -8,7 +8,8 @@ import com.codeinc.gen.messaging.v1.MessagingService.OpenMessageStreamResponse import com.codeinc.gen.messaging.v1.MessagingService.PollMessagesRequest import com.codeinc.gen.messaging.v1.MessagingService.SendMessageRequest import com.codeinc.gen.messaging.v1.MessagingService.SendMessageResponse -import com.getcode.network.core.GrpcApi +import com.getcode.annotations.CodeManagedChannel +import com.getcode.services.network.core.GrpcApi import io.grpc.ManagedChannel import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Scheduler @@ -17,6 +18,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers import javax.inject.Inject class MessagingApi @Inject constructor( + @CodeManagedChannel managedChannel: ManagedChannel, private val scheduler: Scheduler = Schedulers.io() ) : GrpcApi(managedChannel) { diff --git a/api/src/main/java/com/getcode/network/api/PhoneApi.kt b/services/code/src/main/java/com/getcode/network/api/PhoneApi.kt similarity index 92% rename from api/src/main/java/com/getcode/network/api/PhoneApi.kt rename to services/code/src/main/java/com/getcode/network/api/PhoneApi.kt index 257b12c94..eeb93dc4b 100644 --- a/api/src/main/java/com/getcode/network/api/PhoneApi.kt +++ b/services/code/src/main/java/com/getcode/network/api/PhoneApi.kt @@ -2,7 +2,8 @@ package com.getcode.network.api import com.codeinc.gen.phone.v1.PhoneVerificationGrpc import com.codeinc.gen.phone.v1.PhoneVerificationService -import com.getcode.network.core.GrpcApi +import com.getcode.annotations.CodeManagedChannel +import com.getcode.services.network.core.GrpcApi import io.grpc.ManagedChannel import io.reactivex.rxjava3.annotations.NonNull import io.reactivex.rxjava3.core.Scheduler @@ -11,6 +12,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers import javax.inject.Inject class PhoneApi @Inject constructor( + @CodeManagedChannel managedChannel: ManagedChannel, private val scheduler: Scheduler = Schedulers.io(), ) : GrpcApi(managedChannel) { diff --git a/api/src/main/java/com/getcode/network/api/PushApi.kt b/services/code/src/main/java/com/getcode/network/api/PushApi.kt similarity index 86% rename from api/src/main/java/com/getcode/network/api/PushApi.kt rename to services/code/src/main/java/com/getcode/network/api/PushApi.kt index df887df44..4e59faa57 100644 --- a/api/src/main/java/com/getcode/network/api/PushApi.kt +++ b/services/code/src/main/java/com/getcode/network/api/PushApi.kt @@ -2,7 +2,8 @@ package com.getcode.network.api import com.codeinc.gen.push.v1.PushGrpc import com.codeinc.gen.push.v1.PushService -import com.getcode.network.core.GrpcApi +import com.getcode.annotations.CodeManagedChannel +import com.getcode.services.network.core.GrpcApi import io.grpc.ManagedChannel import io.reactivex.rxjava3.annotations.NonNull import io.reactivex.rxjava3.core.Scheduler @@ -11,6 +12,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers import javax.inject.Inject class PushApi @Inject constructor( + @CodeManagedChannel managedChannel: ManagedChannel, private val scheduler: Scheduler = Schedulers.io(), ) : GrpcApi(managedChannel) { diff --git a/api/src/main/java/com/getcode/network/api/TransactionApiV2.kt b/services/code/src/main/java/com/getcode/network/api/TransactionApiV2.kt similarity index 96% rename from api/src/main/java/com/getcode/network/api/TransactionApiV2.kt rename to services/code/src/main/java/com/getcode/network/api/TransactionApiV2.kt index df78516f2..ef85cc626 100644 --- a/api/src/main/java/com/getcode/network/api/TransactionApiV2.kt +++ b/services/code/src/main/java/com/getcode/network/api/TransactionApiV2.kt @@ -4,7 +4,8 @@ import com.codeinc.gen.transaction.v2.TransactionGrpc import com.codeinc.gen.transaction.v2.TransactionService import com.codeinc.gen.transaction.v2.TransactionService.SwapRequest import com.codeinc.gen.transaction.v2.TransactionService.SwapResponse -import com.getcode.network.core.GrpcApi +import com.getcode.annotations.CodeManagedChannel +import com.getcode.services.network.core.GrpcApi import io.grpc.ManagedChannel import io.grpc.stub.StreamObserver import io.reactivex.rxjava3.core.Scheduler @@ -16,6 +17,7 @@ import kotlinx.coroutines.flow.flowOn import javax.inject.Inject class TransactionApiV2 @Inject constructor( + @CodeManagedChannel managedChannel: ManagedChannel, private val scheduler: Scheduler = Schedulers.io(), ) : GrpcApi(managedChannel) { diff --git a/api/src/main/java/com/getcode/network/client/Client.kt b/services/code/src/main/java/com/getcode/network/client/Client.kt similarity index 89% rename from api/src/main/java/com/getcode/network/client/Client.kt rename to services/code/src/main/java/com/getcode/network/client/Client.kt index fd5d982e6..f5cda261d 100644 --- a/api/src/main/java/com/getcode/network/client/Client.kt +++ b/services/code/src/main/java/com/getcode/network/client/Client.kt @@ -1,7 +1,9 @@ package com.getcode.network.client +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.OnLifecycleEvent import com.getcode.analytics.AnalyticsService -import com.getcode.manager.MnemonicManager import com.getcode.manager.SessionManager import com.getcode.network.BalanceController import com.getcode.network.exchange.Exchange @@ -10,9 +12,10 @@ import com.getcode.network.repository.IdentityRepository import com.getcode.network.repository.MessagingRepository import com.getcode.network.repository.PrefRepository import com.getcode.network.repository.TransactionRepository -import com.getcode.network.service.ChatServiceV1 -import com.getcode.network.service.ChatServiceV2 +import com.getcode.network.service.AccountService +import com.getcode.network.service.ChatService import com.getcode.network.service.DeviceService +import com.getcode.services.manager.MnemonicManager import com.getcode.utils.ErrorUtils import com.getcode.utils.network.NetworkConnectivityListener import kotlinx.coroutines.CoroutineScope @@ -45,11 +48,10 @@ class Client @Inject constructor( internal val exchange: Exchange, internal val transactionReceiver: TransactionReceiver, internal val networkObserver: NetworkConnectivityListener, - internal val chatServiceV1: ChatServiceV1, - internal val chatServiceV2: ChatServiceV2, + internal val chatService: ChatService, internal val deviceService: DeviceService, internal val mnemonicManager: MnemonicManager, -) { +) : LifecycleObserver { private val scope = CoroutineScope(Dispatchers.IO) @@ -100,10 +102,12 @@ class Client @Inject constructor( } } + @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) fun startTimer() { startPollTimerWhenAuthenticated() } + @OnLifecycleEvent(Lifecycle.Event.ON_STOP) fun stopTimer() { Timber.tag(TAG).i("Cancelling Poller") pollTimer?.cancel() diff --git a/api/src/main/java/com/getcode/network/client/Client_Account.kt b/services/code/src/main/java/com/getcode/network/client/Client_Account.kt similarity index 100% rename from api/src/main/java/com/getcode/network/client/Client_Account.kt rename to services/code/src/main/java/com/getcode/network/client/Client_Account.kt diff --git a/services/code/src/main/java/com/getcode/network/client/Client_Chat.kt b/services/code/src/main/java/com/getcode/network/client/Client_Chat.kt new file mode 100644 index 000000000..68fbc8a40 --- /dev/null +++ b/services/code/src/main/java/com/getcode/network/client/Client_Chat.kt @@ -0,0 +1,87 @@ +package com.getcode.network.client + +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.manager.SessionManager +import com.getcode.model.Cursor +import com.getcode.model.Domain +import com.getcode.model.ID +import com.getcode.model.chat.Chat +import com.getcode.model.chat.ChatMessage +import com.getcode.model.chat.MessageStatus +import com.getcode.model.chat.NotificationCollectionEntity +import com.getcode.model.extensions.decryptingUsing +import com.getcode.utils.TraceType +import com.getcode.utils.base58 +import com.getcode.utils.trace +import timber.log.Timber + +suspend fun Client.fetchChats(owner: KeyPair): Result> { + val chats = chatService.fetchChats(owner) + .onSuccess { + Timber.d("v1 chats fetched=${it.count()}") + }.onFailure { + trace( + "Failed fetching chats from V1", + type = TraceType.Error + ) + } + + return chats + .map { list -> + list.sortedByDescending { it.lastMessageMillis } + .distinctBy { it.id } + } +} + +suspend fun Client.setMuted(owner: KeyPair, chat: Chat, muted: Boolean): Result { + return chatService.setMuteState(owner, chat.id, muted) +} + +suspend fun Client.setSubscriptionState( + owner: KeyPair, + chat: Chat, + subscribed: Boolean +): Result { + return chatService.setSubscriptionState(owner, chat.id, subscribed) +} + +suspend fun Client.fetchMessagesFor( + owner: KeyPair, + chat: Chat, + cursor: Cursor? = null, + limit: Int? = null +): Result> { + return chatService.fetchMessagesFor(owner, chat, cursor, limit) + .mapCatching { messages -> + val organizer = SessionManager.getOrganizer() ?: return@mapCatching messages + val domain = Domain.from(chat.title?.value) ?: return@mapCatching messages + + val relationship = organizer.relationshipFor(domain) ?: return@mapCatching messages + + val hasEncryptedContent = messages.firstOrNull { it.hasEncryptedContent } != null + if (hasEncryptedContent) { + messages.map { message -> + message.decryptingUsing(relationship.getCluster().authority.keyPair) + } + } else { + messages + } + } + .onSuccess { + Timber.d("messages fetched=${it.count()} for ${chat.id.base58}") + if (it.isNotEmpty()) { + Timber.d("start=${it.minOf { it.dateMillis }}, end=${it.maxOf { it.dateMillis }}") + } + }.onFailure { + Timber.e(t = it, "Failed fetching messages.") + } +} + +suspend fun Client.advancePointer( + owner: KeyPair, + chat: Chat, + to: ID, + status: MessageStatus = MessageStatus.Read, +): Result { + return chatService.advancePointer(owner, chat.id, to, status) +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/client/Client_Device.kt b/services/code/src/main/java/com/getcode/network/client/Client_Device.kt similarity index 100% rename from api/src/main/java/com/getcode/network/client/Client_Device.kt rename to services/code/src/main/java/com/getcode/network/client/Client_Device.kt diff --git a/api/src/main/java/com/getcode/network/client/Client_Identity.kt b/services/code/src/main/java/com/getcode/network/client/Client_Identity.kt similarity index 82% rename from api/src/main/java/com/getcode/network/client/Client_Identity.kt rename to services/code/src/main/java/com/getcode/network/client/Client_Identity.kt index 452450e2a..6c0de5895 100644 --- a/api/src/main/java/com/getcode/network/client/Client_Identity.kt +++ b/services/code/src/main/java/com/getcode/network/client/Client_Identity.kt @@ -2,11 +2,10 @@ package com.getcode.network.client import com.getcode.ed25519.Ed25519 import com.getcode.model.TwitterUser -import com.getcode.solana.keys.PublicKey import com.getcode.solana.organizer.Organizer import java.util.Locale -suspend fun Client.loginToThirdParty(rendezvous: PublicKey, relationship: Ed25519.KeyPair): Result { +suspend fun Client.loginToThirdParty(rendezvous: com.getcode.solana.keys.PublicKey, relationship: Ed25519.KeyPair): Result { return identityRepository.loginToThirdParty(rendezvous, relationship) } @@ -26,7 +25,7 @@ suspend fun Client.fetchTwitterUser( suspend fun Client.fetchTwitterUser( organizer: Organizer, - address: PublicKey + address: com.getcode.solana.keys.PublicKey ): Result { return identityRepository.fetchTwitterUserByAddress(organizer.ownerKeyPair, address) } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/client/Client_Messaging.kt b/services/code/src/main/java/com/getcode/network/client/Client_Messaging.kt similarity index 92% rename from api/src/main/java/com/getcode/network/client/Client_Messaging.kt rename to services/code/src/main/java/com/getcode/network/client/Client_Messaging.kt index fd8762242..357f3cf65 100644 --- a/api/src/main/java/com/getcode/network/client/Client_Messaging.kt +++ b/services/code/src/main/java/com/getcode/network/client/Client_Messaging.kt @@ -4,7 +4,6 @@ import com.codeinc.gen.messaging.v1.MessagingService import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.model.Domain import com.getcode.model.Fiat -import com.getcode.solana.keys.PublicKey suspend fun Client.sendRequestToLogin( domain: Domain, @@ -15,7 +14,7 @@ suspend fun Client.sendRequestToLogin( } suspend fun Client.sendRequestToReceiveBill( - destination: PublicKey, + destination: com.getcode.solana.keys.PublicKey, fiat: Fiat, rendezvous: KeyPair ): Result { diff --git a/api/src/main/java/com/getcode/network/client/Client_Transaction.kt b/services/code/src/main/java/com/getcode/network/client/Client_Transaction.kt similarity index 96% rename from api/src/main/java/com/getcode/network/client/Client_Transaction.kt rename to services/code/src/main/java/com/getcode/network/client/Client_Transaction.kt index 1e12347ad..97cc1ebe3 100644 --- a/api/src/main/java/com/getcode/network/client/Client_Transaction.kt +++ b/services/code/src/main/java/com/getcode/network/client/Client_Transaction.kt @@ -1,13 +1,10 @@ package com.getcode.network.client import android.annotation.SuppressLint -import com.getcode.api.BuildConfig -import com.getcode.db.Database +import com.getcode.db.CodeAppDatabase import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.manager.SessionManager -import com.getcode.manager.TopBarManager import com.getcode.model.AccountInfo -import com.getcode.model.SocialUser import com.getcode.model.Domain import com.getcode.model.Fee import com.getcode.model.GiftCard @@ -17,6 +14,7 @@ import com.getcode.model.Kin import com.getcode.model.KinAmount import com.getcode.model.Limits import com.getcode.model.Rate +import com.getcode.model.generate import com.getcode.model.intents.IntentDeposit import com.getcode.model.intents.IntentEstablishRelationship import com.getcode.model.intents.IntentPrivateTransfer @@ -27,13 +25,13 @@ import com.getcode.model.intents.SwapIntent import com.getcode.network.repository.TransactionRepository import com.getcode.network.repository.WithdrawException import com.getcode.network.repository.initiateSwap +import com.getcode.services.utils.flowInterval import com.getcode.solana.keys.PublicKey import com.getcode.solana.keys.base58 import com.getcode.solana.organizer.GiftCardAccount import com.getcode.solana.organizer.Organizer import com.getcode.solana.organizer.Relationship import com.getcode.utils.TraceType -import com.getcode.utils.flowInterval import com.getcode.utils.trace import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Single @@ -158,7 +156,7 @@ fun Client.sendRemotely( amount = truncatedAmount.kin.quarks, date = System.currentTimeMillis() ) - Database.requireInstance().giftCardDao().insert(giftCardItem) + CodeAppDatabase.requireInstance().giftCardDao().insert(giftCardItem) } ) } @@ -472,7 +470,7 @@ private var lastLimitsFetch: Long = 0L fun Client.fetchLimits(isForce: Boolean = false): Completable { val owner = SessionManager.getKeyPair() ?: return Completable.complete() - if (!Database.isOpen()) return Completable.complete() + if (!CodeAppDatabase.isOpen()) return Completable.complete() fetchTransactionLimits(owner, isForce) return Completable.complete() } @@ -558,14 +556,6 @@ fun Client.fetchPrivacyUpgrades(): Completable { actionCount = intent.actions.size ) Timber.i("Privacy Upgrade - success") - - if (BuildConfig.DEBUG) { - TopBarManager.showMessage( - "Privacy Upgrade", - "Success. Index: $index, Count: ${intents.size}", - TopBarManager.TopBarMessageType.NOTIFICATION - ) - } } .doOnError { analyticsManager.upgradePrivacy( @@ -574,9 +564,6 @@ fun Client.fetchPrivacyUpgrades(): Completable { actionCount = intent.actions.size ) Timber.i("Privacy Upgrade - failure") - if (BuildConfig.DEBUG) { - TopBarManager.showMessage("Privacy Upgrade", "Failure") - } } .ignoreElement() diff --git a/api/src/main/java/com/getcode/network/client/TransactionReceiver.kt b/services/code/src/main/java/com/getcode/network/client/TransactionReceiver.kt similarity index 98% rename from api/src/main/java/com/getcode/network/client/TransactionReceiver.kt rename to services/code/src/main/java/com/getcode/network/client/TransactionReceiver.kt index 48764a281..d73b72ad0 100644 --- a/api/src/main/java/com/getcode/network/client/TransactionReceiver.kt +++ b/services/code/src/main/java/com/getcode/network/client/TransactionReceiver.kt @@ -138,7 +138,10 @@ class TransactionReceiver @Inject constructor( } fun receiveFromIncoming(amount: Kin, organizer: Organizer): Completable { - trace("receiveFromIncoming $amount", type = TraceType.Silent) + trace( + "receiveFromIncoming $amount", + type = TraceType.Silent + ) return transactionRepository.receiveFromIncoming( context, amount, organizer ).map { diff --git a/api/src/main/java/com/getcode/network/exchange/Exchange.kt b/services/code/src/main/java/com/getcode/network/exchange/Exchange.kt similarity index 70% rename from api/src/main/java/com/getcode/network/exchange/Exchange.kt rename to services/code/src/main/java/com/getcode/network/exchange/Exchange.kt index 9ef2f9a30..c7216c9c8 100644 --- a/api/src/main/java/com/getcode/network/exchange/Exchange.kt +++ b/services/code/src/main/java/com/getcode/network/exchange/Exchange.kt @@ -1,21 +1,18 @@ package com.getcode.network.exchange -import com.getcode.db.Database -import com.getcode.model.Currency -import com.getcode.model.CurrencyCode -import com.getcode.model.PrefsString +import com.getcode.db.CodeAppDatabase +import com.getcode.services.model.PrefsString import com.getcode.model.Rate import com.getcode.network.repository.PrefRepository import com.getcode.network.service.CurrencyService import com.getcode.utils.TraceType -import com.getcode.utils.format +import com.getcode.util.format import com.getcode.utils.network.retryable import com.getcode.utils.trace import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull @@ -26,67 +23,15 @@ import java.util.Date import javax.inject.Inject import kotlin.time.Duration.Companion.minutes -interface Exchange { - val localRate: Rate - fun observeLocalRate(): Flow - - val entryRate: Rate - fun observeEntryRate(): Flow - - fun rates(): Map - fun observeRates(): Flow> - - suspend fun fetchRatesIfNeeded() - - fun rateFor(currencyCode: CurrencyCode): Rate? - - fun rateForUsd(): Rate? -} - -class ExchangeNull : Exchange { - override val localRate: Rate - get() = Rate.oneToOne - - override val entryRate: Rate - get() = Rate.oneToOne - - - override fun observeLocalRate(): Flow { - return emptyFlow() - } - - override fun observeEntryRate(): Flow { - return emptyFlow() - } - - override fun rates(): Map { - return emptyMap() - } - - override fun observeRates(): Flow> { - return emptyFlow() - } - - override suspend fun fetchRatesIfNeeded() = Unit - - override fun rateFor(currencyCode: CurrencyCode): Rate? { - return null - } - - override fun rateForUsd(): Rate? { - return null - } - -} class CodeExchange @Inject constructor( private val currencyService: CurrencyService, prefs: PrefRepository, - private val preferredCurrency: suspend () -> Currency?, - private val defaultCurrency: suspend () -> Currency?, + private val preferredCurrency: suspend () -> com.getcode.model.Currency?, + private val defaultCurrency: suspend () -> com.getcode.model.Currency?, ) : Exchange, CoroutineScope by CoroutineScope(Dispatchers.IO) { - private val db = Database.getInstance() + private val db = CodeAppDatabase.getInstance() private var _entryRate = MutableStateFlow(Rate.oneToOne) override val entryRate: Rate @@ -102,10 +47,10 @@ class CodeExchange @Inject constructor( private var rateDate: Long = System.currentTimeMillis() - private var localCurrency: CurrencyCode? = null - private var entryCurrency: CurrencyCode? = null + private var localCurrency: com.getcode.model.CurrencyCode? = null + private var entryCurrency: com.getcode.model.CurrencyCode? = null - private val _rates = MutableStateFlow(emptyMap()) + private val _rates = MutableStateFlow(emptyMap()) private var rates = RatesBox(0, emptyMap()) set(value) { field = value @@ -113,7 +58,7 @@ class CodeExchange @Inject constructor( } override fun rates() = rates.rates - override fun observeRates(): Flow> = _rates + override fun observeRates(): Flow> = _rates private val isStale: Boolean get() { @@ -128,14 +73,14 @@ class CodeExchange @Inject constructor( init { launch { - localCurrency = CurrencyCode.tryValueOf(preferredCurrency()?.code.orEmpty()) - entryCurrency = CurrencyCode.tryValueOf(defaultCurrency()?.code.orEmpty()) + localCurrency = com.getcode.model.CurrencyCode.tryValueOf(preferredCurrency()?.code.orEmpty()) + entryCurrency = com.getcode.model.CurrencyCode.tryValueOf(defaultCurrency()?.code.orEmpty()) prefs.observeOrDefault(PrefsString.KEY_ENTRY_CURRENCY, "") .map { it.takeIf { it.isNotEmpty() } } - .map { CurrencyCode.tryValueOf(it.orEmpty()) } + .map { com.getcode.model.CurrencyCode.tryValueOf(it.orEmpty()) } .mapNotNull { preferred -> - preferred ?: CurrencyCode.tryValueOf(defaultCurrency()?.code.orEmpty()) + preferred ?: com.getcode.model.CurrencyCode.tryValueOf(defaultCurrency()?.code.orEmpty()) }.onEach { setEntryCurrency(it) } .launchIn(this@CodeExchange) } @@ -152,9 +97,9 @@ class CodeExchange @Inject constructor( prefs.observeOrDefault(PrefsString.KEY_LOCAL_CURRENCY, "") .map { it.takeIf { it.isNotEmpty() } } - .map { CurrencyCode.tryValueOf(it.orEmpty()) } + .map { com.getcode.model.CurrencyCode.tryValueOf(it.orEmpty()) } .mapNotNull { preferred -> - preferred ?: CurrencyCode.tryValueOf(defaultCurrency()?.code.orEmpty()) + preferred ?: com.getcode.model.CurrencyCode.tryValueOf(defaultCurrency()?.code.orEmpty()) }.onEach { setLocalCurrency(it) } .launchIn(this) } @@ -175,12 +120,12 @@ class CodeExchange @Inject constructor( updateRates() } - private fun setEntryCurrency(currency: CurrencyCode) { + private fun setEntryCurrency(currency: com.getcode.model.CurrencyCode) { entryCurrency = currency updateRates() } - private fun setLocalCurrency(currency: CurrencyCode) { + private fun setLocalCurrency(currency: com.getcode.model.CurrencyCode) { localCurrency = currency updateRates() } @@ -199,11 +144,11 @@ class CodeExchange @Inject constructor( } val localRegionCurrency = defaultCurrency() ?: return - val currency = CurrencyCode.tryValueOf(localRegionCurrency.code) + val currency = com.getcode.model.CurrencyCode.tryValueOf(localRegionCurrency.code) entryCurrency = currency } - override fun rateFor(currencyCode: CurrencyCode): Rate? = rates.rateFor(currencyCode) + override fun rateFor(currencyCode: com.getcode.model.CurrencyCode): Rate? = rates.rateFor(currencyCode) override fun rateForUsd(): Rate? = rates.rateForUsd() @@ -270,7 +215,7 @@ class CodeExchange @Inject constructor( } } -private data class RatesBox(val dateMillis: Long, val rates: Map) { +private data class RatesBox(val dateMillis: Long, val rates: Map) { constructor(dateMillis: Long, rates: List) : this( dateMillis, rates.associateBy { it.currency }) @@ -278,14 +223,14 @@ private data class RatesBox(val dateMillis: Long, val rates: Map cameraGesturesEnabled PrefsBool.CAMERA_DRAG_INVERTED -> invertedDragZoom PrefsBool.CHAT_UNSUB_ENABLED -> chatUnsubEnabled - PrefsBool.CONVERSATIONS_ENABLED -> conversationsEnabled PrefsBool.CONVERSATION_CASH_ENABLED -> conversationCashEnabled PrefsBool.DISPLAY_ERRORS -> displayErrors PrefsBool.GALLERY_ENABLED -> galleryEnabled diff --git a/services/code/src/main/java/com/getcode/network/repository/Extensions.kt b/services/code/src/main/java/com/getcode/network/repository/Extensions.kt new file mode 100644 index 000000000..0fc90890d --- /dev/null +++ b/services/code/src/main/java/com/getcode/network/repository/Extensions.kt @@ -0,0 +1,42 @@ +package com.getcode.network.repository + +import com.codeinc.gen.common.v1.Model +import com.getcode.ed25519.Ed25519 +import com.getcode.utils.toByteString +import com.google.protobuf.MessageLite +import java.io.ByteArrayOutputStream + +fun isMock() = false + +fun ByteArray.toUserId(): Model.UserId { + return Model.UserId.newBuilder().setValue(this.toByteString()).build() +} + +fun String.toPhoneNumber(): Model.PhoneNumber { + return Model.PhoneNumber.newBuilder().setValue(this).build() +} + +fun List.toSolanaAccount(): Model.SolanaAccountId { + return Model.SolanaAccountId.newBuilder().setValue(this.toByteArray().toByteString()) + .build() +} + +fun ByteArray.toSolanaAccount(): Model.SolanaAccountId { + return Model.SolanaAccountId.newBuilder().setValue(this.toByteString()) + .build() +} + +fun ByteArray.toSignature(): Model.Signature { + return Model.Signature.newBuilder().setValue(this.toByteString()) + .build() +} + +fun com.getcode.solana.keys.PublicKey.toIntentId(): Model.IntentId { + return Model.IntentId.newBuilder().setValue(this.byteArray.toByteString()).build() +} + +fun MessageLite.Builder.sign(owner: Ed25519.KeyPair): Model.Signature { + val bos = ByteArrayOutputStream() + this.buildPartial().writeTo(bos) + return Ed25519.sign(bos.toByteArray(), owner).toSignature() +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/repository/FeatureRepository.kt b/services/code/src/main/java/com/getcode/network/repository/FeatureRepository.kt similarity index 92% rename from api/src/main/java/com/getcode/network/repository/FeatureRepository.kt rename to services/code/src/main/java/com/getcode/network/repository/FeatureRepository.kt index b111a0451..c83cbb1b6 100644 --- a/api/src/main/java/com/getcode/network/repository/FeatureRepository.kt +++ b/services/code/src/main/java/com/getcode/network/repository/FeatureRepository.kt @@ -3,12 +3,11 @@ package com.getcode.network.repository import com.getcode.model.BalanceCurrencyFeature import com.getcode.model.BuyModuleFeature import com.getcode.model.CameraGesturesFeature -import com.getcode.model.PrefsBool +import com.getcode.services.model.PrefsBool import com.getcode.model.RequestKinFeature import com.getcode.model.TipCardFeature import com.getcode.model.TipCardOnHomeScreenFeature import com.getcode.model.ConversationCashFeature -import com.getcode.model.ConversationsFeature import com.getcode.model.FlippableTipCardFeature import com.getcode.model.GalleryFeature import com.getcode.model.InvertedDragZoomFeature @@ -32,7 +31,6 @@ class FeatureRepository @Inject constructor( val tipCardOnHomeScreen = betaFlags.observe().map { TipCardOnHomeScreenFeature(it.tipCardOnHomeScreen) } val tipCardFlippable = betaFlags.observe().map { FlippableTipCardFeature(it.canFlipTipCard) } - val conversations = betaFlags.observe().map { ConversationsFeature(it.conversationsEnabled) } val conversationsCash = betaFlags.observe().map { ConversationCashFeature(it.conversationCashEnabled) } diff --git a/api/src/main/java/com/getcode/network/repository/IdentityRepository.kt b/services/code/src/main/java/com/getcode/network/repository/IdentityRepository.kt similarity index 89% rename from api/src/main/java/com/getcode/network/repository/IdentityRepository.kt rename to services/code/src/main/java/com/getcode/network/repository/IdentityRepository.kt index e749a68ff..002ba1d6a 100644 --- a/api/src/main/java/com/getcode/network/repository/IdentityRepository.kt +++ b/services/code/src/main/java/com/getcode/network/repository/IdentityRepository.kt @@ -7,24 +7,25 @@ import com.codeinc.gen.user.v1.IdentityService.GetTwitterUserRequest import com.codeinc.gen.user.v1.IdentityService.LoginToThirdPartyAppRequest import com.codeinc.gen.user.v1.IdentityService.LoginToThirdPartyAppResponse import com.codeinc.gen.user.v1.IdentityService.UpdatePreferencesRequest -import com.getcode.db.Database -import com.getcode.ed25519.Ed25519 +import com.getcode.db.CodeAppDatabase import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.model.AirdropType -import com.getcode.model.PrefsBool -import com.getcode.model.PrefsString +import com.getcode.services.model.PrefsBool +import com.getcode.services.model.PrefsString import com.getcode.model.TwitterUser -import com.getcode.network.core.NetworkOracle +import com.getcode.model.protomapping.invoke import com.getcode.network.api.IdentityApi -import com.getcode.solana.keys.PublicKey +import com.getcode.services.network.core.NetworkOracle import com.getcode.utils.ErrorUtils +import com.getcode.utils.decodeBase64 +import com.getcode.utils.encodeBase64 +import com.getcode.utils.toByteString import com.google.common.collect.Sets import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Single import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import timber.log.Timber -import java.time.Instant import java.util.Locale import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -88,7 +89,7 @@ class IdentityRepository @Inject constructor( // view inconsistencies with expected state, since I suspect Database.isOpen() // is false by the time we execute this piece of code in some flows (eg. in // particular I've noticed logging in through seed phrase input). - if (Database.isOpen()) { + if (CodeAppDatabase.isOpen()) { prefRepository.set( PrefsString.KEY_USER_ID, user.userId.toByteArray().encodeBase64() @@ -158,8 +159,8 @@ class IdentityRepository @Inject constructor( keyPair: KeyPair, phoneValue: String, code: String - ): Single { - if (isMock()) return Single.just(IdentityService.LinkAccountResponse.Result.OK) + ): Single { + if (isMock()) return Single.just(LinkAccountResult.Success) .delay(1, TimeUnit.SECONDS) val request = @@ -180,14 +181,23 @@ class IdentityRepository @Inject constructor( return identityApi.linkAccount(request) .map { it.result } .let { networkOracle.managedRequest(it) } + .map { + when (it) { + IdentityService.LinkAccountResponse.Result.OK -> LinkAccountResult.Success + IdentityService.LinkAccountResponse.Result.INVALID_TOKEN -> LinkAccountResult.Error.InvalidCode + IdentityService.LinkAccountResponse.Result.RATE_LIMITED -> LinkAccountResult.Error.RateLimit + IdentityService.LinkAccountResponse.Result.UNRECOGNIZED -> LinkAccountResult.Error.Unrecognized + else -> LinkAccountResult.Error.Other + } + } .firstOrError() } fun unlinkAccount( keyPair: KeyPair, phoneValue: String - ): Single { - if (isMock()) return Single.just(IdentityService.UnlinkAccountResponse.Result.OK) + ): Single { + if (isMock()) return Single.just(UnlinkAccountResult.Success) .delay(1, TimeUnit.SECONDS) val request = @@ -200,6 +210,12 @@ class IdentityRepository @Inject constructor( return identityApi.unlinkAccount(request) .map { it.result } .let { networkOracle.managedRequest(it) } + .map { + when (it) { + IdentityService.UnlinkAccountResponse.Result.OK -> UnlinkAccountResult.Success + else -> UnlinkAccountResult.Error + } + } .doOnComplete { phoneRepository.phoneNumber = "" phoneRepository.phoneLinked.value = false @@ -261,7 +277,7 @@ class IdentityRepository @Inject constructor( } suspend fun loginToThirdParty( - rendezvous: PublicKey, + rendezvous: com.getcode.solana.keys.PublicKey, relationship: KeyPair ): Result { val request = LoginToThirdPartyAppRequest.newBuilder() @@ -350,7 +366,7 @@ class IdentityRepository @Inject constructor( } } - suspend fun fetchTwitterUserByAddress(owner: KeyPair, address: PublicKey): Result { + suspend fun fetchTwitterUserByAddress(owner: KeyPair, address: com.getcode.solana.keys.PublicKey): Result { val request = GetTwitterUserRequest.newBuilder() .setRequestor(owner.publicKeyBytes.toSolanaAccount()) .setTipAddress(address.byteArray.toSolanaAccount()) @@ -403,3 +419,18 @@ sealed class TwitterUserFetchError : Exception() { class NotFound: TwitterUserFetchError() class FailedToParse: TwitterUserFetchError() } + +sealed interface LinkAccountResult { + data object Success: LinkAccountResult + sealed interface Error: LinkAccountResult { + data object InvalidCode : Error + data object RateLimit: Error + data object Unrecognized : Error + data object Other: Error + } +} + +sealed interface UnlinkAccountResult { + data object Success: UnlinkAccountResult + data object Error: UnlinkAccountResult +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/repository/MessagingRepository.kt b/services/code/src/main/java/com/getcode/network/repository/MessagingRepository.kt similarity index 93% rename from api/src/main/java/com/getcode/network/repository/MessagingRepository.kt rename to services/code/src/main/java/com/getcode/network/repository/MessagingRepository.kt index bb025e5ff..04ff5c561 100644 --- a/api/src/main/java/com/getcode/network/repository/MessagingRepository.kt +++ b/services/code/src/main/java/com/getcode/network/repository/MessagingRepository.kt @@ -7,19 +7,20 @@ import com.codeinc.gen.messaging.v1.MessagingService.CodeScanned import com.codeinc.gen.messaging.v1.MessagingService.PollMessagesRequest import com.codeinc.gen.messaging.v1.MessagingService.RendezvousKey import com.codeinc.gen.transaction.v2.TransactionService -import com.google.protobuf.ByteString import com.getcode.ed25519.Ed25519 import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.model.Domain import com.getcode.model.Fiat -import com.getcode.solana.keys.Signature import com.getcode.model.PaymentRequest import com.getcode.model.StreamMessage -import com.getcode.solana.keys.PublicKey -import com.getcode.network.core.NetworkOracle +import com.getcode.model.toPublicKey import com.getcode.network.api.MessagingApi -import com.getcode.network.core.INFINITE_STREAM_TIMEOUT +import com.getcode.services.network.core.INFINITE_STREAM_TIMEOUT +import com.getcode.services.network.core.NetworkOracle import com.getcode.utils.ErrorUtils +import com.getcode.utils.getPublicKeyBase58 +import com.getcode.utils.hexEncodedString +import com.google.protobuf.ByteString import com.google.protobuf.Timestamp import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable @@ -72,7 +73,9 @@ class MessagingRepository @Inject constructor( val account = message.requestToGrabBill.requestorAccount.value.toByteArray().toPublicKey() val signature = - Signature(message.sendMessageRequestSignature.value.toByteArray().toList()) + com.getcode.solana.keys.Signature( + message.sendMessageRequestSignature.value.toByteArray().toList() + ) PaymentRequest(account, signature) }.first() } @@ -108,9 +111,9 @@ class MessagingRepository @Inject constructor( } fun verifyRequestToGrabBill( - destination: PublicKey, + destination: com.getcode.solana.keys.PublicKey, rendezvousKey: KeyPair, - signature: Signature + signature: com.getcode.solana.keys.Signature ): Boolean { val messageData = sendRequestToGrabBill(destination = destination).build().toByteArray() return rendezvousKey.verify(signature.byteArray, messageData) @@ -142,7 +145,7 @@ class MessagingRepository @Inject constructor( } suspend fun sendRequestToReceiveBill( - destination: PublicKey, + destination: com.getcode.solana.keys.PublicKey, fiat: Fiat, rendezvous: KeyPair ): Result { @@ -191,7 +194,7 @@ class MessagingRepository @Inject constructor( suspend fun rejectPayment(rendezvous: KeyPair): Result { val rejection = MessagingService.ClientRejectedPayment.newBuilder() - .setIntentId(PublicKey.fromBase58(rendezvous.getPublicKeyBase58()).toIntentId()) + .setIntentId(com.getcode.solana.keys.PublicKey.fromBase58(rendezvous.getPublicKeyBase58()).toIntentId()) .build() val message = MessagingService.Message.newBuilder() @@ -213,7 +216,7 @@ class MessagingRepository @Inject constructor( return sendRendezvousMessage(message, rendezvous) } - private fun sendRequestToGrabBill(destination: PublicKey): MessagingService.Message.Builder { + private fun sendRequestToGrabBill(destination: com.getcode.solana.keys.PublicKey): MessagingService.Message.Builder { return MessagingService.Message .newBuilder() .setRequestToGrabBill( diff --git a/api/src/main/java/com/getcode/network/repository/ObservableVariable.kt b/services/code/src/main/java/com/getcode/network/repository/ObservableVariable.kt similarity index 100% rename from api/src/main/java/com/getcode/network/repository/ObservableVariable.kt rename to services/code/src/main/java/com/getcode/network/repository/ObservableVariable.kt diff --git a/api/src/main/java/com/getcode/network/repository/PaymentRepository.kt b/services/code/src/main/java/com/getcode/network/repository/PaymentRepository.kt similarity index 98% rename from api/src/main/java/com/getcode/network/repository/PaymentRepository.kt rename to services/code/src/main/java/com/getcode/network/repository/PaymentRepository.kt index 3ed89a66e..7cf2beb78 100644 --- a/api/src/main/java/com/getcode/network/repository/PaymentRepository.kt +++ b/services/code/src/main/java/com/getcode/network/repository/PaymentRepository.kt @@ -4,13 +4,16 @@ import android.annotation.SuppressLint import com.getcode.analytics.AnalyticsService import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.manager.SessionManager -import com.getcode.model.CodePayload +import com.getcode.services.model.CodePayload import com.getcode.model.ID import com.getcode.model.Kin import com.getcode.model.KinAmount import com.getcode.model.LoginRequest import com.getcode.model.SocialUser +import com.getcode.model.fromFiatAmount +import com.getcode.model.generate import com.getcode.model.intents.PrivateTransferMetadata +import com.getcode.model.toPublicKey import com.getcode.network.BalanceController import com.getcode.network.client.Client import com.getcode.network.client.establishRelationshipSingle @@ -231,7 +234,7 @@ class PaymentRepository @Inject constructor( } } - suspend fun payForFriendship(user: SocialUser, amount: KinAmount): ID { + suspend fun payForFriendship(user: SocialUser, amount: KinAmount): ID { return suspendCancellableCoroutine { cont -> val organizer = SessionManager.getOrganizer() ?: throw PaymentError.OrganizerNotFound() diff --git a/api/src/main/java/com/getcode/network/repository/PhoneRepository.kt b/services/code/src/main/java/com/getcode/network/repository/PhoneRepository.kt similarity index 54% rename from api/src/main/java/com/getcode/network/repository/PhoneRepository.kt rename to services/code/src/main/java/com/getcode/network/repository/PhoneRepository.kt index e66d0916a..3fca0be72 100644 --- a/api/src/main/java/com/getcode/network/repository/PhoneRepository.kt +++ b/services/code/src/main/java/com/getcode/network/repository/PhoneRepository.kt @@ -1,12 +1,12 @@ package com.getcode.network.repository import com.codeinc.gen.phone.v1.PhoneVerificationService -import com.getcode.db.Database +import com.getcode.db.CodeAppDatabase import com.getcode.ed25519.Ed25519 import com.getcode.network.api.PhoneApi -import com.getcode.network.core.NetworkOracle import com.getcode.network.integrity.DeviceCheck import com.getcode.network.integrity.toDeviceToken +import com.getcode.services.network.core.NetworkOracle import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Single import kotlinx.coroutines.flow.MutableStateFlow @@ -31,9 +31,8 @@ class PhoneRepository @Inject constructor( fun sendVerificationCode( phoneValue: String - ): Flowable { - if (isMock()) return Single.just(PhoneVerificationService.SendVerificationCodeResponse.Result.OK) - .toFlowable() + ): Flowable { + if (isMock()) return Single.just(OtpVerificationResult.Success).toFlowable() return DeviceCheck.integrityResponseFlowable() .flatMap { tokenResult -> @@ -50,14 +49,27 @@ class PhoneRepository @Inject constructor( phoneApi.sendVerificationCode(request) .map { it.result } .let { networkOracle.managedRequest(it) } + .map { ret -> + when (ret) { + PhoneVerificationService.SendVerificationCodeResponse.Result.OK -> OtpVerificationResult.Success + PhoneVerificationService.SendVerificationCodeResponse.Result.NOT_INVITED -> OtpVerificationResult.Error.NotInvited + PhoneVerificationService.SendVerificationCodeResponse.Result.RATE_LIMITED -> OtpVerificationResult.Error.RateLimited + PhoneVerificationService.SendVerificationCodeResponse.Result.INVALID_PHONE_NUMBER -> OtpVerificationResult.Error.InvalidPhoneNumber + PhoneVerificationService.SendVerificationCodeResponse.Result.UNSUPPORTED_PHONE_TYPE -> OtpVerificationResult.Error.UnsupportedPhoneType + PhoneVerificationService.SendVerificationCodeResponse.Result.UNSUPPORTED_COUNTRY -> OtpVerificationResult.Error.UnsupportedCountry + PhoneVerificationService.SendVerificationCodeResponse.Result.UNSUPPORTED_DEVICE -> OtpVerificationResult.Error.UnsupportedDevice + PhoneVerificationService.SendVerificationCodeResponse.Result.UNRECOGNIZED -> OtpVerificationResult.Error.Unrecognized + else -> OtpVerificationResult.Error.Other + } + } } } fun checkVerificationCode( phoneValue: String, otpInput: String - ): Flowable { - if (isMock()) return Flowable.just(PhoneVerificationService.CheckVerificationCodeResponse.Result.OK) + ): Flowable { + if (isMock()) return Flowable.just(CheckVerificationResult.Success) val request = PhoneVerificationService.CheckVerificationCodeRequest.newBuilder() .setPhoneNumber(phoneValue.toPhoneNumber()) @@ -67,6 +79,15 @@ class PhoneRepository @Inject constructor( return phoneApi.checkVerificationCode(request) .map { it.result } .let { networkOracle.managedRequest(it) } + .map { result -> + when (result) { + PhoneVerificationService.CheckVerificationCodeResponse.Result.OK -> CheckVerificationResult.Success + PhoneVerificationService.CheckVerificationCodeResponse.Result.INVALID_CODE -> CheckVerificationResult.Error.InvalidCode + PhoneVerificationService.CheckVerificationCodeResponse.Result.NO_VERIFICATION -> CheckVerificationResult.Error.NoVerification + PhoneVerificationService.CheckVerificationCodeResponse.Result.UNRECOGNIZED -> CheckVerificationResult.Error.Unrecognized + else -> CheckVerificationResult.Error.Other + } + } } fun fetchAssociatedPhoneNumber( @@ -99,7 +120,7 @@ class PhoneRepository @Inject constructor( phone.phoneNumber.value ) } - .flatMap { response -> Database.isInit.map { response } } + .flatMap { response -> CodeAppDatabase.isInit.map { response } } .doOnNext { phone -> phoneNumber = phone.phoneNumber phoneLinked.value = phone.isLinked @@ -115,4 +136,28 @@ class PhoneRepository @Inject constructor( GetAssociatedPhoneNumberResponse(true, v1.value, false, v2) } } +} + +sealed interface OtpVerificationResult { + data object Success: OtpVerificationResult + sealed interface Error: OtpVerificationResult { + data object InvalidPhoneNumber : Error + data object NotInvited: Error + data object RateLimited: Error + data object UnsupportedPhoneType : Error + data object UnsupportedCountry : Error + data object UnsupportedDevice : Error + data object Unrecognized : Error + data object Other: Error + } +} + +sealed interface CheckVerificationResult { + data object Success: CheckVerificationResult + sealed interface Error: CheckVerificationResult { + data object InvalidCode : Error + data object NoVerification : Error + data object Unrecognized : Error + data object Other: Error + } } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/repository/PrefRepository.kt b/services/code/src/main/java/com/getcode/network/repository/PrefRepository.kt similarity index 76% rename from api/src/main/java/com/getcode/network/repository/PrefRepository.kt rename to services/code/src/main/java/com/getcode/network/repository/PrefRepository.kt index 433f1aed3..39addf2ac 100644 --- a/api/src/main/java/com/getcode/network/repository/PrefRepository.kt +++ b/services/code/src/main/java/com/getcode/network/repository/PrefRepository.kt @@ -1,29 +1,29 @@ package com.getcode.network.repository -import com.getcode.db.Database -import com.getcode.model.* +import com.getcode.db.CodeAppDatabase +import com.getcode.services.model.PrefBool +import com.getcode.services.model.PrefInt +import com.getcode.services.model.PrefString +import com.getcode.services.model.PrefsBool +import com.getcode.services.model.PrefsInt +import com.getcode.services.model.PrefsString import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEmpty import kotlinx.coroutines.launch import kotlinx.coroutines.reactive.asFlow import timber.log.Timber import javax.inject.Inject - class PrefRepository @Inject constructor(): CoroutineScope by CoroutineScope(Dispatchers.IO) { suspend fun get(key: PrefsString, default: String): String { @@ -40,7 +40,7 @@ class PrefRepository @Inject constructor(): CoroutineScope by CoroutineScope(Dis fun getFlowable(key: PrefsString): Flowable { - val db = Database.getInstance() ?: return Flowable.empty() + val db = CodeAppDatabase.getInstance() ?: return Flowable.empty() return db.prefStringDao().get(key.value) .subscribeOn(Schedulers.computation()) .map { it.value } @@ -48,7 +48,7 @@ class PrefRepository @Inject constructor(): CoroutineScope by CoroutineScope(Dis } fun getFlowable(key: PrefsBool): Flowable { - val db = Database.getInstance() ?: return Flowable.empty() + val db = CodeAppDatabase.getInstance() ?: return Flowable.empty() return db.prefBoolDao().get(key.value) .subscribeOn(Schedulers.computation()) .map { it.value } @@ -56,9 +56,9 @@ class PrefRepository @Inject constructor(): CoroutineScope by CoroutineScope(Dis } fun observeOrDefault(key: PrefsBool, default: Boolean): Flow { - return Database.isInit + return CodeAppDatabase.isInit .asFlow() - .map { Database.getInstance() } + .map { CodeAppDatabase.getInstance() } .flatMapLatest { it ?: return@flatMapLatest flowOf(default) it.prefBoolDao().observe(key.value).map { it?.value ?: default } @@ -68,9 +68,9 @@ class PrefRepository @Inject constructor(): CoroutineScope by CoroutineScope(Dis } fun observeOrDefault(key: PrefsString, default: String): Flow { - return Database.isInit + return CodeAppDatabase.isInit .asFlow() - .map { Database.getInstance() } + .map { CodeAppDatabase.getInstance() } .flatMapLatest { it ?: return@flatMapLatest flowOf(default).also { Timber.e("observe string ; DB not available") } it.prefStringDao().observe(key.value) @@ -80,9 +80,9 @@ class PrefRepository @Inject constructor(): CoroutineScope by CoroutineScope(Dis } fun observeOrDefault(key: PrefsInt, default: Long): Flow { - return Database.isInit + return CodeAppDatabase.isInit .asFlow() - .map { Database.getInstance() } + .map { CodeAppDatabase.getInstance() } .flatMapLatest { it ?: return@flatMapLatest flowOf(default).also { Timber.e("observe long ; DB not available") } it.prefIntDao().observe(key.value) @@ -92,7 +92,7 @@ class PrefRepository @Inject constructor(): CoroutineScope by CoroutineScope(Dis } fun getFlowable(key: String): Flowable { - val db = Database.getInstance() ?: return Flowable.empty() + val db = CodeAppDatabase.getInstance() ?: return Flowable.empty() return db.prefIntDao().get(key) .subscribeOn(Schedulers.computation()) .map { it.value } @@ -100,7 +100,7 @@ class PrefRepository @Inject constructor(): CoroutineScope by CoroutineScope(Dis } fun getFirstOrDefault(key: PrefsString, default: String): Single { - val db = Database.getInstance() ?: return Single.just(default) + val db = CodeAppDatabase.getInstance() ?: return Single.just(default) return db.prefStringDao().getMaybe(key.value) .subscribeOn(Schedulers.computation()) .map { it.value } @@ -108,7 +108,7 @@ class PrefRepository @Inject constructor(): CoroutineScope by CoroutineScope(Dis } fun getFirstOrDefault(key: PrefsBool, default: Boolean): Single { - val db = Database.getInstance() ?: return Single.just(default) + val db = CodeAppDatabase.getInstance() ?: return Single.just(default) return db.prefBoolDao().getMaybe(key.value) .subscribeOn(Schedulers.computation()) .map { it.value } @@ -116,7 +116,7 @@ class PrefRepository @Inject constructor(): CoroutineScope by CoroutineScope(Dis } fun getFirstOrDefault(key: String, default: Int): Single { - val db = Database.getInstance() ?: return Single.just(default.toLong()) + val db = CodeAppDatabase.getInstance() ?: return Single.just(default.toLong()) return db.prefIntDao().getMaybe(key) .subscribeOn(Schedulers.computation()) .map { it.value } @@ -125,7 +125,7 @@ class PrefRepository @Inject constructor(): CoroutineScope by CoroutineScope(Dis suspend fun set(vararg list: Pair) { list.forEach { pair -> - Database.getInstance()?.prefStringDao()?.insert(PrefString(pair.first.value, pair.second)) + CodeAppDatabase.getInstance()?.prefStringDao()?.insert(PrefString(pair.first.value, pair.second)) } } @@ -137,14 +137,14 @@ class PrefRepository @Inject constructor(): CoroutineScope by CoroutineScope(Dis fun set(key: String, value: Long) { launch { - Database.getInstance()?.prefIntDao()?.insert(PrefInt(key, value)) + CodeAppDatabase.getInstance()?.prefIntDao()?.insert(PrefInt(key, value)) } } fun set(key: PrefsBool, value: Boolean) { launch { runCatching { - val db = Database.getInstance() ?: throw IllegalStateException("No DB") + val db = CodeAppDatabase.getInstance() ?: throw IllegalStateException("No DB") db.prefBoolDao().insert(PrefBool(key.value, value)) }.onFailure { Timber.d(it.message) }.onSuccess { Timber.d("saved ${key.value} => $value") } } diff --git a/api/src/main/java/com/getcode/network/repository/PushRepository.kt b/services/code/src/main/java/com/getcode/network/repository/PushRepository.kt similarity index 91% rename from api/src/main/java/com/getcode/network/repository/PushRepository.kt rename to services/code/src/main/java/com/getcode/network/repository/PushRepository.kt index 5131c1fe0..60ca74346 100644 --- a/api/src/main/java/com/getcode/network/repository/PushRepository.kt +++ b/services/code/src/main/java/com/getcode/network/repository/PushRepository.kt @@ -2,19 +2,17 @@ package com.getcode.network.repository import com.codeinc.gen.common.v1.Model import com.codeinc.gen.push.v1.PushService -import com.getcode.ed25519.Ed25519 import com.getcode.manager.SessionManager -import com.getcode.model.PrefsString +import com.getcode.services.model.PrefsString import com.getcode.network.api.PushApi -import com.getcode.network.core.NetworkOracle +import com.getcode.services.network.core.NetworkOracle import com.getcode.utils.ErrorUtils -import com.google.firebase.installations.installations -import io.reactivex.rxjava3.core.Flowable +import com.getcode.utils.decodeBase64 +import com.getcode.utils.toByteString import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.reactive.asFlow import timber.log.Timber -import java.io.ByteArrayOutputStream import javax.inject.Inject class PushRepository @Inject constructor( diff --git a/api/src/main/java/com/getcode/network/repository/ReceiveTransactionRepository.kt b/services/code/src/main/java/com/getcode/network/repository/ReceiveTransactionRepository.kt similarity index 97% rename from api/src/main/java/com/getcode/network/repository/ReceiveTransactionRepository.kt rename to services/code/src/main/java/com/getcode/network/repository/ReceiveTransactionRepository.kt index b52b2a327..62532bb88 100644 --- a/api/src/main/java/com/getcode/network/repository/ReceiveTransactionRepository.kt +++ b/services/code/src/main/java/com/getcode/network/repository/ReceiveTransactionRepository.kt @@ -3,6 +3,7 @@ package com.getcode.network.repository import com.codeinc.gen.messaging.v1.MessagingService import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.model.IntentMetadata +import com.getcode.model.toPublicKey import com.getcode.network.client.Client import com.getcode.network.client.pollIntentMetadata import com.getcode.solana.organizer.Organizer diff --git a/api/src/main/java/com/getcode/network/repository/SendTransactionRepository.kt b/services/code/src/main/java/com/getcode/network/repository/SendTransactionRepository.kt similarity index 96% rename from api/src/main/java/com/getcode/network/repository/SendTransactionRepository.kt rename to services/code/src/main/java/com/getcode/network/repository/SendTransactionRepository.kt index e6bb4947b..5ecbe18fc 100644 --- a/api/src/main/java/com/getcode/network/repository/SendTransactionRepository.kt +++ b/services/code/src/main/java/com/getcode/network/repository/SendTransactionRepository.kt @@ -2,17 +2,18 @@ package com.getcode.network.repository import com.getcode.analytics.AnalyticsService import com.getcode.ed25519.Ed25519 -import com.getcode.model.CodePayload +import com.getcode.services.model.CodePayload import com.getcode.model.IntentMetadata import com.getcode.model.Kin import com.getcode.model.KinAmount -import com.getcode.model.Kind +import com.getcode.services.model.Kind +import com.getcode.model.toPublicKey import com.getcode.network.client.Client import com.getcode.network.client.pollIntentMetadata import com.getcode.network.client.transfer import com.getcode.solana.keys.PublicKey import com.getcode.solana.organizer.Organizer -import com.getcode.utils.nonce +import com.getcode.services.utils.nonce import io.reactivex.rxjava3.core.Flowable import kotlinx.coroutines.rx3.asFlowable import javax.inject.Inject diff --git a/api/src/main/java/com/getcode/network/repository/StatusRepository.kt b/services/code/src/main/java/com/getcode/network/repository/StatusRepository.kt similarity index 100% rename from api/src/main/java/com/getcode/network/repository/StatusRepository.kt rename to services/code/src/main/java/com/getcode/network/repository/StatusRepository.kt diff --git a/api/src/main/java/com/getcode/network/repository/TransactionRepository.kt b/services/code/src/main/java/com/getcode/network/repository/TransactionRepository.kt similarity index 96% rename from api/src/main/java/com/getcode/network/repository/TransactionRepository.kt rename to services/code/src/main/java/com/getcode/network/repository/TransactionRepository.kt index 1bbecc861..f05733482 100644 --- a/api/src/main/java/com/getcode/network/repository/TransactionRepository.kt +++ b/services/code/src/main/java/com/getcode/network/repository/TransactionRepository.kt @@ -10,10 +10,11 @@ import com.codeinc.gen.transaction.v2.TransactionService.SubmitIntentResponse import com.codeinc.gen.transaction.v2.TransactionService.SubmitIntentResponse.ResponseCase.ERROR import com.codeinc.gen.transaction.v2.TransactionService.SubmitIntentResponse.ResponseCase.SERVER_PARAMETERS import com.codeinc.gen.transaction.v2.TransactionService.SubmitIntentResponse.ResponseCase.SUCCESS -import com.getcode.api.BuildConfig +import com.getcode.services.BuildConfig import com.getcode.crypt.MnemonicPhrase import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.model.* +import com.getcode.model.extensions.newInstance import com.getcode.model.intents.ActionGroup import com.getcode.model.intents.IntentCreateAccounts import com.getcode.model.intents.IntentDeposit @@ -34,8 +35,6 @@ import com.getcode.solana.SolanaTransaction import com.getcode.solana.diff import com.getcode.solana.keys.AssociatedTokenAccount import com.getcode.solana.keys.Mint -import com.getcode.solana.keys.PublicKey -import com.getcode.solana.keys.Signature import com.getcode.solana.keys.base58 import com.getcode.solana.organizer.AccountType import com.getcode.solana.organizer.GiftCardAccount @@ -44,6 +43,7 @@ import com.getcode.solana.organizer.Relationship import com.getcode.utils.ErrorUtils import com.getcode.utils.TraceType import com.getcode.utils.bytes +import com.getcode.utils.toByteString import com.getcode.utils.trace import com.google.protobuf.Timestamp import dagger.hilt.android.qualifiers.ApplicationContext @@ -116,7 +116,7 @@ class TransactionRepository @Inject constructor( fun createAccounts(organizer: Organizer): Single { if (isMock()) return Single.just( IntentCreateAccounts( - id = PublicKey(bytes = listOf()), + id = com.getcode.solana.keys.PublicKey(bytes = listOf()), actionGroup = ActionGroup(), organizer = organizer ) as IntentType @@ -136,14 +136,14 @@ class TransactionRepository @Inject constructor( fee: Kin, additionalFees: List, organizer: Organizer, - rendezvousKey: PublicKey, - destination: PublicKey, + rendezvousKey: com.getcode.solana.keys.PublicKey, + destination: com.getcode.solana.keys.PublicKey, isWithdrawal: Boolean, metadata: PrivateTransferMetadata? = null, ): Single { if (isMock()) return Single.just( IntentPrivateTransfer( - id = PublicKey(bytes = listOf()), + id = com.getcode.solana.keys.PublicKey(bytes = listOf()), actionGroup = ActionGroup(), organizer = organizer, destination = destination, @@ -230,7 +230,7 @@ class TransactionRepository @Inject constructor( fun withdraw( amount: KinAmount, organizer: Organizer, - destination: PublicKey + destination: com.getcode.solana.keys.PublicKey ): Single { val intent = IntentPublicTransfer.newInstance( organizer = organizer, @@ -256,7 +256,7 @@ class TransactionRepository @Inject constructor( fun sendRemotely( amount: KinAmount, organizer: Organizer, - rendezvousKey: PublicKey, + rendezvousKey: com.getcode.solana.keys.PublicKey, giftCard: GiftCardAccount ): Single { val intent = IntentRemoteSend.newInstance( @@ -358,7 +358,11 @@ class TransactionRepository @Inject constructor( errors.addAll( listOf( "Action index: ${error.invalidSignature.actionId}", - "Invalid signature: ${Signature(error.invalidSignature.providedSignature.value.toByteArray().toList()).base58()}", + "Invalid signature: ${ + com.getcode.solana.keys.Signature( + error.invalidSignature.providedSignature.value.toByteArray() + .toList() + ).base58()}", "Transaction bytes: ${error.invalidSignature.expectedTransaction.value}", "Transaction expected: $expected", "Android produced: $produced" @@ -529,7 +533,7 @@ class TransactionRepository @Inject constructor( suspend fun fetchIntentMetadata( owner: KeyPair, - intentId: PublicKey + intentId: com.getcode.solana.keys.PublicKey ): Result { val request = TransactionService.GetIntentMetadataRequest.newBuilder() .setIntentId(intentId.toIntentId()) @@ -599,7 +603,7 @@ class TransactionRepository @Inject constructor( .toFlowable() } - fun fetchDestinationMetadata(destination: PublicKey): Single { + fun fetchDestinationMetadata(destination: com.getcode.solana.keys.PublicKey): Single { val request = TransactionService.CanWithdrawToAccountRequest.newBuilder() .setAccount(destination.bytes.toSolanaAccount()) .build() @@ -653,12 +657,12 @@ class TransactionRepository @Inject constructor( } data class DestinationMetadata( - val destination: PublicKey, + val destination: com.getcode.solana.keys.PublicKey, val isValid: Boolean, val kind: Kind, val hasResolvedDestination: Boolean, - val resolvedDestination: PublicKey + val resolvedDestination: com.getcode.solana.keys.PublicKey ) { enum class Kind { Unknown, @@ -678,12 +682,12 @@ class TransactionRepository @Inject constructor( companion object { fun newInstance( - destination: PublicKey, + destination: com.getcode.solana.keys.PublicKey, isValid: Boolean, kind: Kind ): DestinationMetadata { val hasResolvedDestination: Boolean - val resolvedDestination: PublicKey + val resolvedDestination: com.getcode.solana.keys.PublicKey when (kind) { Kind.Unknown, Kind.TokenAccount -> { diff --git a/api/src/main/java/com/getcode/network/repository/TransactionRepository_Swap.kt b/services/code/src/main/java/com/getcode/network/repository/TransactionRepository_Swap.kt similarity index 94% rename from api/src/main/java/com/getcode/network/repository/TransactionRepository_Swap.kt rename to services/code/src/main/java/com/getcode/network/repository/TransactionRepository_Swap.kt index 2933e978e..f2f0d7a82 100644 --- a/api/src/main/java/com/getcode/network/repository/TransactionRepository_Swap.kt +++ b/services/code/src/main/java/com/getcode/network/repository/TransactionRepository_Swap.kt @@ -7,10 +7,9 @@ import com.codeinc.gen.transaction.v2.TransactionService.SwapResponse import com.getcode.model.intents.SwapConfigParameters import com.getcode.model.intents.SwapIntent import com.getcode.model.intents.requestToSubmitSignatures -import com.getcode.network.core.BidirectionalStreamReference +import com.getcode.services.observers.BidirectionalStreamReference import com.getcode.solana.SolanaTransaction import com.getcode.solana.diff -import com.getcode.solana.keys.Signature import com.getcode.solana.keys.base58 import com.getcode.solana.organizer.Organizer import com.getcode.utils.ErrorUtils @@ -82,7 +81,11 @@ private suspend fun TransactionRepository.submit(intent: SwapIntent): Result>> { @@ -159,7 +158,7 @@ class ChatServiceV1 @Inject constructor( when (response.result) { ChatService.GetMessagesResponse.Result.OK -> { Result.success(response.messagesList.map { - messageMapper.map(chat to it) + messageMapper.map(it) }) } diff --git a/api/src/main/java/com/getcode/network/service/CurrencyService.kt b/services/code/src/main/java/com/getcode/network/service/CurrencyService.kt similarity index 81% rename from api/src/main/java/com/getcode/network/service/CurrencyService.kt rename to services/code/src/main/java/com/getcode/network/service/CurrencyService.kt index fd99cda8e..513184fd0 100644 --- a/api/src/main/java/com/getcode/network/service/CurrencyService.kt +++ b/services/code/src/main/java/com/getcode/network/service/CurrencyService.kt @@ -1,9 +1,8 @@ package com.getcode.network.service -import com.getcode.model.CurrencyCode import com.getcode.model.Rate import com.getcode.network.api.CurrencyApi -import com.getcode.network.core.NetworkOracle +import com.getcode.services.network.core.NetworkOracle import com.getcode.utils.ErrorUtils import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -29,12 +28,12 @@ class CurrencyService @Inject constructor( networkOracle.managedRequest(api.getRates()) .map { response -> val rates = response.ratesMap.mapNotNull { (key, value) -> - val currency = CurrencyCode.tryValueOf(key) ?: return@mapNotNull null + val currency = com.getcode.model.CurrencyCode.tryValueOf(key) ?: return@mapNotNull null Rate(fx = value, currency = currency) }.toMutableList() - if (rates.none { it.currency == CurrencyCode.KIN }) { - rates.add(Rate(fx = 1.0, currency = CurrencyCode.KIN)) + if (rates.none { it.currency == com.getcode.model.CurrencyCode.KIN }) { + rates.add(Rate(fx = 1.0, currency = com.getcode.model.CurrencyCode.KIN)) } Result.success(ApiRateResult( diff --git a/api/src/main/java/com/getcode/network/service/DeviceService.kt b/services/code/src/main/java/com/getcode/network/service/DeviceService.kt similarity index 92% rename from api/src/main/java/com/getcode/network/service/DeviceService.kt rename to services/code/src/main/java/com/getcode/network/service/DeviceService.kt index caaa2bc3f..588d9a0e6 100644 --- a/api/src/main/java/com/getcode/network/service/DeviceService.kt +++ b/services/code/src/main/java/com/getcode/network/service/DeviceService.kt @@ -3,7 +3,7 @@ package com.getcode.network.service import com.codeinc.gen.device.v1.DeviceService import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.network.api.DeviceApi -import com.getcode.network.core.NetworkOracle +import com.getcode.services.network.core.NetworkOracle import com.getcode.solana.keys.PublicKey import com.getcode.utils.ErrorUtils import kotlinx.coroutines.flow.first @@ -53,7 +53,11 @@ class DeviceService @Inject constructor( .map { response -> when (response.result) { DeviceService.GetLoggedInAccountsResponse.Result.OK -> { - Result.success(response.ownersList.map { PublicKey(it.value.toByteArray().toList()) }) + Result.success(response.ownersList.map { + PublicKey( + it.value.toByteArray().toList() + ) + }) } DeviceService.GetLoggedInAccountsResponse.Result.UNRECOGNIZED -> { val error = Throwable("Error: Unrecognized request.") diff --git a/api/src/main/java/com/getcode/network/source/CollectionPagingSource.kt b/services/code/src/main/java/com/getcode/network/source/CollectionPagingSource.kt similarity index 98% rename from api/src/main/java/com/getcode/network/source/CollectionPagingSource.kt rename to services/code/src/main/java/com/getcode/network/source/CollectionPagingSource.kt index f9c7c86b2..c634ad51a 100644 --- a/api/src/main/java/com/getcode/network/source/CollectionPagingSource.kt +++ b/services/code/src/main/java/com/getcode/network/source/CollectionPagingSource.kt @@ -3,7 +3,6 @@ package com.getcode.network.source import androidx.paging.PagingSource import androidx.paging.PagingState import com.getcode.ed25519.Ed25519.KeyPair -import com.getcode.model.chat.Chat import com.getcode.model.chat.ChatMessage import com.getcode.model.Cursor import com.getcode.model.chat.NotificationCollectionEntity diff --git a/api/src/main/java/com/getcode/model/PrefBool.kt b/services/code/src/main/java/com/getcode/services/model/PrefsBool.kt similarity index 69% rename from api/src/main/java/com/getcode/model/PrefBool.kt rename to services/code/src/main/java/com/getcode/services/model/PrefsBool.kt index 7fff45016..8e932dbf9 100644 --- a/api/src/main/java/com/getcode/model/PrefBool.kt +++ b/services/code/src/main/java/com/getcode/services/model/PrefsBool.kt @@ -1,39 +1,16 @@ -@file:Suppress("ClassName") +package com.getcode.services.model -package com.getcode.model - -import androidx.room.Entity -import androidx.room.PrimaryKey -import dagger.internal.Beta - -@Entity -data class PrefBool( - @PrimaryKey val key: String, - val value: Boolean -) - -// Used internally to control logic and UI -sealed interface InternalRouting // User setting exposed in Settings -> App Settings sealed interface AppSetting -// Beta flag exposed in Settings -> Beta Flags to enable bleeding edge features -sealed interface BetaFlag -// Dev settings -sealed interface DevSetting -// This removes it from the UI in Settings -> Beta Flags -sealed interface Immutable -// Once a feature behind a beta flag is made public, it becomes immutable -sealed interface Launched: Immutable -// A feature flag can also be deemed deprecated and is also then immutable -sealed interface Deprecated : Immutable - sealed class PrefsBool(val value: String) { // internal routing data object IS_DEBUG_ACTIVE: PrefsBool("debug_menu_active"), InternalRouting data object IS_DEBUG_ALLOWED: PrefsBool("debug_menu_allowed"), InternalRouting - data object IS_ELIGIBLE_GET_FIRST_KIN_AIRDROP: PrefsBool("is_eligible_get_first_kin_airdrop"), InternalRouting - data object IS_ELIGIBLE_GIVE_FIRST_KIN_AIRDROP: PrefsBool("is_eligible_give_first_kin_airdrop"), InternalRouting + data object IS_ELIGIBLE_GET_FIRST_KIN_AIRDROP: PrefsBool("is_eligible_get_first_kin_airdrop"), + InternalRouting + data object IS_ELIGIBLE_GIVE_FIRST_KIN_AIRDROP: PrefsBool("is_eligible_give_first_kin_airdrop"), + InternalRouting data object HAS_REMOVED_LOCAL_CURRENCY: PrefsBool("removed_local_currency"), InternalRouting data object DISMISSED_TIP_CARD_BANNER : PrefsBool("dismissed_tip_card_banner"), InternalRouting data object SEEN_TIP_CARD : PrefsBool("seen_tip_card"), InternalRouting @@ -46,7 +23,8 @@ sealed class PrefsBool(val value: String) { data object REQUIRE_BIOMETRICS: PrefsBool("require_biometrics"), AppSetting // dev settings - data object ESTABLISH_CODE_RELATIONSHIP : PrefsBool("establish_code_relationship_enabled"), DevSetting + data object ESTABLISH_CODE_RELATIONSHIP : PrefsBool("establish_code_relationship_enabled"), + DevSetting // beta flags data object BUCKET_DEBUGGER_ENABLED: PrefsBool("debug_buckets"), BetaFlag @@ -58,9 +36,9 @@ sealed class PrefsBool(val value: String) { data object BUY_MODULE_ENABLED : PrefsBool("buy_kin_enabled"), BetaFlag, Launched data object CHAT_UNSUB_ENABLED: PrefsBool("chat_unsub_enabled"), BetaFlag data object TIPS_ENABLED : PrefsBool("tips_enabled"), BetaFlag, Launched - data object CONVERSATIONS_ENABLED: PrefsBool("conversations_enabled"), BetaFlag data object CONVERSATION_CASH_ENABLED: PrefsBool("convo_cash_enabled"), BetaFlag - data object BALANCE_CURRENCY_SELECTION_ENABLED: PrefsBool("balance_currency_enabled"), BetaFlag, Launched + data object BALANCE_CURRENCY_SELECTION_ENABLED: PrefsBool("balance_currency_enabled"), BetaFlag, + Launched data object KADO_WEBVIEW_ENABLED : PrefsBool("kado_inapp_enabled"), BetaFlag data object SHARE_TWEET_TO_TIP : PrefsBool("share_tweet_to_tip"), BetaFlag, Launched data object TIP_CARD_ON_HOMESCREEN: PrefsBool("tip_card_on_home_screen"), BetaFlag, Launched @@ -70,4 +48,7 @@ sealed class PrefsBool(val value: String) { data object GALLERY_ENABLED: PrefsBool("gallery_enabled"), BetaFlag, Launched } -val APP_SETTINGS: List = listOf(PrefsBool.CAMERA_START_BY_DEFAULT, PrefsBool.REQUIRE_BIOMETRICS) \ No newline at end of file +val APP_SETTINGS: List = listOf( + PrefsBool.CAMERA_START_BY_DEFAULT, + PrefsBool.REQUIRE_BIOMETRICS +) \ No newline at end of file diff --git a/api/src/main/java/com/getcode/solana/builder/TransactionBuilder.kt b/services/code/src/main/java/com/getcode/solana/builder/TransactionBuilder.kt similarity index 99% rename from api/src/main/java/com/getcode/solana/builder/TransactionBuilder.kt rename to services/code/src/main/java/com/getcode/solana/builder/TransactionBuilder.kt index b1006a985..48ab10177 100644 --- a/api/src/main/java/com/getcode/solana/builder/TransactionBuilder.kt +++ b/services/code/src/main/java/com/getcode/solana/builder/TransactionBuilder.kt @@ -2,12 +2,13 @@ package com.getcode.solana.builder import com.getcode.solana.keys.Hash import com.getcode.model.Kin +import com.getcode.model.extensions.newInstance import com.getcode.model.intents.PrivateTransferMetadata import com.getcode.model.intents.SwapConfigParameters import com.getcode.solana.Instruction import com.getcode.solana.SolanaTransaction import com.getcode.solana.TransferType -import com.getcode.solana.description +import com.getcode.solana.keys.description import com.getcode.solana.instructions.programs.* import com.getcode.solana.keys.Key32.Companion.mock import com.getcode.solana.keys.Key32.Companion.subsidizer diff --git a/api/src/main/java/com/getcode/solana/organizer/AccountCluster.kt b/services/code/src/main/java/com/getcode/solana/organizer/AccountCluster.kt similarity index 97% rename from api/src/main/java/com/getcode/solana/organizer/AccountCluster.kt rename to services/code/src/main/java/com/getcode/solana/organizer/AccountCluster.kt index 920cb56ac..04cffbb2c 100644 --- a/api/src/main/java/com/getcode/solana/organizer/AccountCluster.kt +++ b/services/code/src/main/java/com/getcode/solana/organizer/AccountCluster.kt @@ -1,9 +1,9 @@ package com.getcode.solana.organizer -import android.content.Context import com.getcode.crypt.DerivedKey import com.getcode.crypt.MnemonicPhrase -import com.getcode.network.repository.toPublicKey +import com.getcode.model.extensions.newInstance +import com.getcode.model.toPublicKey import com.getcode.solana.keys.AssociatedTokenAccount import com.getcode.solana.keys.Mint import com.getcode.solana.keys.PublicKey diff --git a/api/src/main/java/com/getcode/solana/organizer/AccountType.kt b/services/code/src/main/java/com/getcode/solana/organizer/AccountType.kt similarity index 88% rename from api/src/main/java/com/getcode/solana/organizer/AccountType.kt rename to services/code/src/main/java/com/getcode/solana/organizer/AccountType.kt index af17f8ea4..278399d91 100644 --- a/api/src/main/java/com/getcode/solana/organizer/AccountType.kt +++ b/services/code/src/main/java/com/getcode/solana/organizer/AccountType.kt @@ -1,7 +1,6 @@ package com.getcode.solana.organizer import com.codeinc.gen.common.v1.Model -import com.getcode.crypt.DerivePath import com.getcode.model.Domain sealed interface AccountType { @@ -35,20 +34,20 @@ sealed interface AccountType { RemoteSend -> 12 } - fun getDerivationPath(index: Int): DerivePath { + fun getDerivationPath(index: Int): com.getcode.crypt.DerivePath { return when (this) { - Primary -> DerivePath.primary - Incoming -> DerivePath.getBucketIncoming(index) - Outgoing -> DerivePath.getBucketOutgoing(index) + Primary -> com.getcode.crypt.DerivePath.primary + Incoming -> com.getcode.crypt.DerivePath.getBucketIncoming(index) + Outgoing -> com.getcode.crypt.DerivePath.getBucketOutgoing(index) is Bucket -> type.getDerivationPath() RemoteSend -> { // Remote send accounts are standard Solana accounts // and should use a standard derivation path that // would be compatible with other 3rd party wallets - DerivePath.primary + com.getcode.crypt.DerivePath.primary } - is Relationship -> DerivePath.relationship(domain) - Swap -> DerivePath.swap + is Relationship -> com.getcode.crypt.DerivePath.relationship(domain) + Swap -> com.getcode.crypt.DerivePath.swap } } diff --git a/api/src/main/java/com/getcode/solana/organizer/GiftCardAccount.kt b/services/code/src/main/java/com/getcode/solana/organizer/GiftCardAccount.kt similarity index 100% rename from api/src/main/java/com/getcode/solana/organizer/GiftCardAccount.kt rename to services/code/src/main/java/com/getcode/solana/organizer/GiftCardAccount.kt diff --git a/api/src/main/java/com/getcode/solana/organizer/Organizer.kt b/services/code/src/main/java/com/getcode/solana/organizer/Organizer.kt similarity index 89% rename from api/src/main/java/com/getcode/solana/organizer/Organizer.kt rename to services/code/src/main/java/com/getcode/solana/organizer/Organizer.kt index ecc5fe89b..4570a61a5 100644 --- a/api/src/main/java/com/getcode/solana/organizer/Organizer.kt +++ b/services/code/src/main/java/com/getcode/solana/organizer/Organizer.kt @@ -1,15 +1,13 @@ package com.getcode.solana.organizer -import com.getcode.crypt.DerivePath import com.getcode.crypt.MnemonicPhrase import com.getcode.model.AccountInfo import com.getcode.model.Domain import com.getcode.model.Kin import com.getcode.model.unusable -import com.getcode.network.repository.getPublicKeyBase58 import com.getcode.solana.keys.* import com.getcode.utils.TraceType -import com.getcode.utils.timedTrace +import com.getcode.utils.getPublicKeyBase58 import com.getcode.utils.trace import timber.log.Timber @@ -79,7 +77,7 @@ class Organizer( fun propagateBalances() { val balances = mutableMapOf() - timedTrace("propagate balances") { + com.getcode.utils.timedTrace("propagate balances") { for ((vaultPublicKey, info) in accountInfos) { if (tray.publicKey(info.accountType) == vaultPublicKey) { balances[info.accountType] = info.balance @@ -149,16 +147,16 @@ enum class Denomination { hundredThousands, millions; - val derivationPath: DerivePath + val derivationPath: com.getcode.crypt.DerivePath get() { return when (this) { - ones -> DerivePath.bucket1 - tens -> DerivePath.bucket10 - hundreds -> DerivePath.bucket100 - thousands -> DerivePath.bucket1k - tenThousands -> DerivePath.bucket10k - hundredThousands -> DerivePath.bucket100k - millions -> DerivePath.bucket1m + ones -> com.getcode.crypt.DerivePath.bucket1 + tens -> com.getcode.crypt.DerivePath.bucket10 + hundreds -> com.getcode.crypt.DerivePath.bucket100 + thousands -> com.getcode.crypt.DerivePath.bucket1k + tenThousands -> com.getcode.crypt.DerivePath.bucket10k + hundredThousands -> com.getcode.crypt.DerivePath.bucket100k + millions -> com.getcode.crypt.DerivePath.bucket1m } } } diff --git a/api/src/main/java/com/getcode/solana/organizer/PartialAccount.kt b/services/code/src/main/java/com/getcode/solana/organizer/PartialAccount.kt similarity index 100% rename from api/src/main/java/com/getcode/solana/organizer/PartialAccount.kt rename to services/code/src/main/java/com/getcode/solana/organizer/PartialAccount.kt diff --git a/api/src/main/java/com/getcode/solana/organizer/Relationship.kt b/services/code/src/main/java/com/getcode/solana/organizer/Relationship.kt similarity index 97% rename from api/src/main/java/com/getcode/solana/organizer/Relationship.kt rename to services/code/src/main/java/com/getcode/solana/organizer/Relationship.kt index 5c265f675..11ac4cf27 100644 --- a/api/src/main/java/com/getcode/solana/organizer/Relationship.kt +++ b/services/code/src/main/java/com/getcode/solana/organizer/Relationship.kt @@ -1,6 +1,5 @@ package com.getcode.solana.organizer -import android.content.Context import com.getcode.crypt.DerivePath import com.getcode.crypt.DerivedKey import com.getcode.crypt.MnemonicPhrase diff --git a/api/src/main/java/com/getcode/solana/organizer/Slot.kt b/services/code/src/main/java/com/getcode/solana/organizer/Slot.kt similarity index 98% rename from api/src/main/java/com/getcode/solana/organizer/Slot.kt rename to services/code/src/main/java/com/getcode/solana/organizer/Slot.kt index 198a51b7a..712c01329 100644 --- a/api/src/main/java/com/getcode/solana/organizer/Slot.kt +++ b/services/code/src/main/java/com/getcode/solana/organizer/Slot.kt @@ -1,6 +1,5 @@ package com.getcode.solana.organizer -import android.content.Context import com.getcode.crypt.DerivePath import com.getcode.crypt.DerivedKey import com.getcode.crypt.MnemonicPhrase diff --git a/api/src/main/java/com/getcode/solana/organizer/Tray.kt b/services/code/src/main/java/com/getcode/solana/organizer/Tray.kt similarity index 97% rename from api/src/main/java/com/getcode/solana/organizer/Tray.kt rename to services/code/src/main/java/com/getcode/solana/organizer/Tray.kt index 2a2888509..e3b59e37d 100644 --- a/api/src/main/java/com/getcode/solana/organizer/Tray.kt +++ b/services/code/src/main/java/com/getcode/solana/organizer/Tray.kt @@ -11,7 +11,7 @@ import com.getcode.model.description import com.getcode.solana.keys.PublicKey import com.getcode.solana.keys.base58 import com.getcode.utils.TraceType -import com.getcode.utils.padded +import com.getcode.services.utils.padded import com.getcode.utils.trace import kotlin.math.min @@ -424,18 +424,27 @@ class Tray( val currentSlot = slots[slots.size - i] // Backwards val smallerSlot = slotDown(currentSlot.type) - trace("$padding o Checking slot: ${currentSlot.type}", type = TraceType.Silent) + trace( + "$padding o Checking slot: ${currentSlot.type}", + type = TraceType.Silent + ) if (smallerSlot == null) { // We're at the lowest denomination // so we can't exchange anymore. - trace("$padding x Last slot", type = TraceType.Silent) + trace( + "$padding x Last slot", + type = TraceType.Silent + ) break } if (currentSlot.billCount() <= 0) { // Nothing to exchange, the current slot is empty. - trace("$padding x Empty", type = TraceType.Silent) + trace( + "$padding x Empty", + type = TraceType.Silent + ) continue } @@ -444,7 +453,10 @@ class Tray( if (smallerSlot.billCount() >= howManyFit - 1) { // No reason to exchange yet, the smaller slot // already has enough bills for most payments - trace("$padding x Enough bills", type = TraceType.Silent) + trace( + "$padding x Enough bills", + type = TraceType.Silent + ) continue } @@ -499,7 +511,10 @@ class Tray( if (largerSlot == null) { // We're at the largest denomination // so we can't exchange anymore. - trace("$padding x Last slot", type = TraceType.Silent) + trace( + "$padding x Last slot", + type = TraceType.Silent + ) break } @@ -513,7 +528,10 @@ class Tray( if (howManyWeHave < ((howManyFit * 2) - 1)) { // We don't have enough bills to exchange, so we can't do // anything in this slot at the moment. - trace("$padding x Not enough bills", type = TraceType.Silent) + trace( + "$padding x Not enough bills", + type = TraceType.Silent + ) continue } diff --git a/api/src/main/java/com/getcode/utils/SignMessage.kt b/services/code/src/main/java/com/getcode/utils/SignMessage.kt similarity index 75% rename from api/src/main/java/com/getcode/utils/SignMessage.kt rename to services/code/src/main/java/com/getcode/utils/SignMessage.kt index 7f7948179..8da7bb1b7 100644 --- a/api/src/main/java/com/getcode/utils/SignMessage.kt +++ b/services/code/src/main/java/com/getcode/utils/SignMessage.kt @@ -7,7 +7,12 @@ import com.google.protobuf.GeneratedMessageLite import java.io.ByteArrayOutputStream fun , B : GeneratedMessageLite.Builder> GeneratedMessageLite.Builder.sign(owner: Ed25519.KeyPair): Model.Signature { + // dump message up until this point into a ByteArray val bos = ByteArrayOutputStream() this.buildPartial().writeTo(bos) + + /** + * sign message up to this point with owner and return as [com.codeinc.gen.common.v1.Model.Signature] + */ return Ed25519.sign(bos.toByteArray(), owner).toSignature() } diff --git a/services/flipchat/chat/.gitignore b/services/flipchat/chat/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/services/flipchat/chat/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/services/flipchat/chat/build.gradle.kts b/services/flipchat/chat/build.gradle.kts new file mode 100644 index 000000000..a0dd7bca1 --- /dev/null +++ b/services/flipchat/chat/build.gradle.kts @@ -0,0 +1,95 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.flipchatNamespace}.services.chat" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + + consumerProguardFiles("consumer-rules.pro") + + buildConfigField("String", "VERSION_NAME", "\"${Packaging.Flipchat.versionName}\"") + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } + + buildFeatures { + buildConfig = true + } +} + +dependencies { + implementation(project(":definitions:flipchat:models")) + implementation(project(":services:flipchat:core")) + api(project(":services:shared")) + implementation(project(":ui:resources")) + + implementation(Libs.kotlinx_coroutines_core) + implementation(Libs.kotlinx_serialization_json) + implementation(Libs.inject) + + implementation(platform(Libs.compose_bom)) + implementation(Libs.compose_ui) + + implementation(Libs.grpc_android) + implementation(Libs.grpc_okhttp) + implementation(Libs.grpc_kotlin) + implementation(Libs.androidx_lifecycle_runtime) + implementation(Libs.androidx_room_runtime) + implementation(Libs.androidx_room_ktx) + implementation(Libs.androidx_room_paging) + implementation(Libs.androidx_room_rxjava3) + implementation(Libs.okhttp) + implementation(Libs.mixpanel) + + implementation(platform(Libs.firebase_bom)) + implementation(Libs.firebase_crashlytics) + implementation(Libs.firebase_installations) + implementation(Libs.firebase_perf) + implementation(Libs.firebase_messaging) + + implementation(Libs.play_integrity) + + implementation(Libs.androidx_paging_runtime) + + kapt(Libs.androidx_room_compiler) + implementation(Libs.sqlcipher) + + api(Libs.google_play_billing_runtime) + api(Libs.google_play_billing_ktx) + + implementation(Libs.fingerprint_pro) + + implementation(Libs.lib_phone_number_google) + + androidTestImplementation(Libs.androidx_junit) + androidTestImplementation(Libs.junit) + androidTestImplementation(Libs.androidx_test_runner) + + implementation(Libs.hilt) + kapt(Libs.hilt_android_compiler) + kapt(Libs.hilt_compiler) + + implementation(Libs.timber) + implementation(Libs.bugsnag) +} diff --git a/services/flipchat/chat/consumer-rules.pro b/services/flipchat/chat/consumer-rules.pro new file mode 100644 index 000000000..5f642a47c --- /dev/null +++ b/services/flipchat/chat/consumer-rules.pro @@ -0,0 +1,6 @@ +# Needed to keep generic signatures +-keepattributes Signature + +-keepclasseswithmembernames class * { + native ; +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/FcChatConfig.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/FcChatConfig.kt new file mode 100644 index 000000000..5c0610b17 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/FcChatConfig.kt @@ -0,0 +1,9 @@ +package xyz.flipchat.services + +import com.getcode.services.ChannelConfig +import xyz.flipchat.services.chat.BuildConfig + +internal data class FcChatConfig( + override val baseUrl: String = "chat.api.flipchat-infra.xyz", + override val userAgent: String = "Flipchat/Chat/Android/${BuildConfig.VERSION_NAME}", +): ChannelConfig diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/billing/BillingClient.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/billing/BillingClient.kt new file mode 100644 index 000000000..505542308 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/billing/BillingClient.kt @@ -0,0 +1,68 @@ +package xyz.flipchat.services.billing + +import android.app.Activity +import androidx.compose.runtime.staticCompositionLocalOf +import com.android.billingclient.api.BillingResult +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlin.time.Duration.Companion.seconds + +sealed interface IapPaymentEvent { + data class OnSuccess(val productId: String) : IapPaymentEvent + data object OnCancelled : IapPaymentEvent + data class OnError(val productId: String, val error: Throwable): IapPaymentEvent +} + +class IapPaymentError(val code: Int, override val message: String): Throwable(message) { + constructor(result: BillingResult): this(result.responseCode, result.debugMessage) +} + +enum class BillingClientState { + Disconnected, + Connecting, + Connected, + ConnectionLost, + Failed; + + fun canConnect() = this == Disconnected || this == ConnectionLost || this == Failed +} + +val LocalBillingClient = staticCompositionLocalOf { StubBillingClient } + +interface BillingClient { + val eventFlow: SharedFlow + val state: StateFlow + + fun connect() + fun disconnect() + fun hasPaidFor(product: IapProduct): Boolean + fun costOf(product: IapProduct): String + suspend fun purchase(activity: Activity, product: IapProduct) +} + +object StubBillingClient: BillingClient { + private val _eventFlow: MutableSharedFlow = MutableSharedFlow() + override val eventFlow: SharedFlow = _eventFlow.asSharedFlow() + + private val _stateFlow = MutableStateFlow(BillingClientState.Disconnected) + override val state: StateFlow = _stateFlow.asStateFlow() + + data class State( + val connected: Boolean = false, + val failedToConnect: Boolean = false, + ) + + override fun connect() = Unit + override fun disconnect() = Unit + override fun hasPaidFor(product: IapProduct): Boolean = false + override fun costOf(product: IapProduct): String = "NOT_DEFINED" + override suspend fun purchase(activity: Activity, product: IapProduct) { + delay(1.seconds) + _eventFlow.emit(IapPaymentEvent.OnSuccess(product.productId)) + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/billing/GooglePlayBillingClient.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/billing/GooglePlayBillingClient.kt new file mode 100644 index 000000000..101e14773 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/billing/GooglePlayBillingClient.kt @@ -0,0 +1,406 @@ +package xyz.flipchat.services.billing + +import android.app.Activity +import android.content.Context +import android.os.Handler +import android.os.Looper +import com.android.billingclient.api.AcknowledgePurchaseParams +import com.android.billingclient.api.BillingClient.BillingResponseCode +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.ConsumeParams +import com.android.billingclient.api.PendingPurchasesParams +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.Purchase.PurchaseState +import com.android.billingclient.api.PurchasesResponseListener +import com.android.billingclient.api.PurchasesUpdatedListener +import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryPurchasesParams +import com.android.billingclient.api.acknowledgePurchase +import com.android.billingclient.api.consumePurchase +import com.getcode.model.uuid +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import com.google.common.collect.ImmutableList +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +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.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import xyz.flipchat.services.internal.network.repository.iap.InAppPurchaseRepository +import xyz.flipchat.services.user.UserManager +import kotlin.math.pow +import com.android.billingclient.api.BillingClient as GooglePlayBillingClient + + +class GooglePlayBillingClient( + @ApplicationContext context: Context, + private val userManager: UserManager, + private val purchaseRepository: InAppPurchaseRepository +) : BillingClient, PurchasesUpdatedListener { + + companion object { + private const val TAG = "IAP" + private val MAX_RETRY_ATTEMPTS = 5 + private var retryAttempt = 0 + private val baseDelayMillis = 1000L // Initial delay: 1 second + + } + + private val scope = CoroutineScope(Dispatchers.IO) + + private val _eventFlow: MutableSharedFlow = MutableSharedFlow() + override val eventFlow: SharedFlow = _eventFlow.asSharedFlow() + + private val _stateFlow = MutableStateFlow(BillingClientState.Disconnected) + override val state: StateFlow = _stateFlow.asStateFlow() + + data class State( + val connected: Boolean = false, + val failedToConnect: Boolean = false, + ) + + private val client = GooglePlayBillingClient.newBuilder(context) + .setListener(this) + .enablePendingPurchases( + PendingPurchasesParams.newBuilder() + .enableOneTimeProducts() + .build() + ) + .build() + + private val productDetails = mutableMapOf() + private val purchases = mutableMapOf() + + override fun onPurchasesUpdated( + billingResult: BillingResult, + purchases: MutableList? + ) { + printLog("onPurchasesUpdated c=${billingResult.responseCode} m=${billingResult.debugMessage}; p=${purchases?.count()}") + if (billingResult.responseCode == BillingResponseCode.OK && purchases != null) { + for (purchase in purchases) { + completePurchase(purchase) + } + } else if (billingResult.responseCode == BillingResponseCode.USER_CANCELED) { + // Handle an error caused by a user canceling the purchase flow. + scope.launch { _eventFlow.emit(IapPaymentEvent.OnCancelled) } + } + } + + override fun connect() { + if (_stateFlow.value.canConnect()) { + _stateFlow.update { BillingClientState.Connecting } + client.startConnection(clientStateListener) + } + } + + override fun disconnect() { + runCatching { + client.endConnection() + _stateFlow.update { BillingClientState.Disconnected } + } + } + + override fun hasPaidFor(product: IapProduct) = + purchases[product.productId] == PurchaseState.PURCHASED + + override fun costOf(product: IapProduct): String { + var details = productDetails[product.productId] + if (details == null) { + queryProduct(product) + details = productDetails[product.productId] + } + + if (details == null) { + scope.launch { + _eventFlow.emit( + IapPaymentEvent.OnError( + product.productId, + Throwable("Unable to resolve product details for ${product.productId}") + ) + ) + } + return " " + } + + return details.oneTimePurchaseOfferDetails?.formattedPrice ?: " " + } + + override suspend fun purchase(activity: Activity, product: IapProduct) { + var details = productDetails[product.productId] + if (details == null) { + queryProduct(product) + details = productDetails[product.productId] + } + + if (details == null) { + _eventFlow.emit( + IapPaymentEvent.OnError( + product.productId, + Throwable("Unable to resolve product details for ${product.productId}") + ) + ) + return + } + + val billingFlowParams = BillingFlowParams.newBuilder() + .setProductDetailsParamsList( + ImmutableList.of( + BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(details) + .build() + ) + ) + .setObfuscatedAccountId(userManager.userId?.uuid.toString()) + .build() + + client.launchBillingFlow(activity, billingFlowParams) + } + + private fun completePurchase(item: Purchase) { + printLog("complete purchase ${item.orderId} ack=${item.isAcknowledged}") + if (!item.isAcknowledged) { + scope.launch { + printLog("onPurchaseComplete for ${item.purchaseToken}") + purchaseRepository.onPurchaseCompleted(item.purchaseToken) + .onSuccess { + acknowledgeOrConsume(item) + }.onFailure { + _eventFlow.emit( + IapPaymentEvent.OnError( + item.products.firstOrNull() ?: "NONE", + it + ) + ) + } + } + + } else { + val productId = item.products.first() + val product = IapProduct.entries.firstOrNull { it.productId == productId } + if (product != null) { + scope.launch { + _eventFlow.emit(IapPaymentEvent.OnSuccess(productId)) + } + } + } + + purchases[item.products.first()] = item.purchaseState + } + + private fun acknowledgeOrConsume(item: Purchase) { + printLog("ack or consume purchase") + val productId = item.products.first() + val product = IapProduct.entries.firstOrNull { it.productId == productId } + if (product != null) { + scope.launch { + if (product.isConsumable) { + printLog("consumable") + val consumeResult = withContext(Dispatchers.IO) { + client.consumePurchase( + ConsumeParams.newBuilder() + .setPurchaseToken(item.purchaseToken) + .build() + ) + } + + if (consumeResult.billingResult.responseCode == BillingResponseCode.OK) { + _eventFlow.emit(IapPaymentEvent.OnSuccess(productId)) + } else { + _eventFlow.emit( + IapPaymentEvent.OnError( + productId, + IapPaymentError(consumeResult.billingResult) + ) + ) + } + } else { + printLog("non-consumable") + val acknowledgeResult = withContext(Dispatchers.IO) { + client.acknowledgePurchase( + AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(item.purchaseToken) + .build() + ) + } + + if (acknowledgeResult.responseCode == BillingResponseCode.OK) { + _eventFlow.emit(IapPaymentEvent.OnSuccess(productId)) + } else { + _eventFlow.emit( + IapPaymentEvent.OnError( + productId, + IapPaymentError(acknowledgeResult) + ) + ) + } + } + } + } + } + + private fun queryProducts() { + IapProduct.entries.onEach { product -> queryProduct(product) } + } + + private fun queryProduct(product: IapProduct) { + val queryProductDetailsParams = QueryProductDetailsParams.newBuilder() + .setProductList( + ImmutableList.of( + QueryProductDetailsParams.Product.newBuilder() + .setProductId(product.productId) + .setProductType(GooglePlayBillingClient.ProductType.INAPP) + .build() + ) + ) + .build() + + client.queryProductDetailsAsync( + queryProductDetailsParams + ) { result, productDetailsList -> + printLog("products for ${product.productId} = ${productDetailsList.count()}") + if (productDetailsList.isNotEmpty()) { + productDetails[product.productId] = productDetailsList.first() + } + } + } + + private fun restorePurchases() { + val queryPurchasesParams = QueryPurchasesParams.newBuilder() + .setProductType(GooglePlayBillingClient.ProductType.INAPP) + .build() + + client.queryPurchasesAsync( + queryPurchasesParams, + restorePurchasesListener + ) + } + + private val restorePurchasesListener = PurchasesResponseListener { _, purchases -> + printLog("restore ${purchases.count()}") + purchases.onEach { completePurchase(it) } + } + + private val clientStateListener = object : BillingClientStateListener { + override fun onBillingSetupFinished( + billingResult: BillingResult + ) { + if (billingResult.responseCode == BillingResponseCode.OK) { + // Billing client connected successfully + printLog("connected!") + retryAttempt = 0 // Reset retry count + + _stateFlow.update { BillingClientState.Connected } + queryProducts() + restorePurchases() + } else { + _stateFlow.update { BillingClientState.Failed } + handleConnectionFailure(billingResult) + } + } + + override fun onBillingServiceDisconnected() { + printLog("connection lost") + _stateFlow.update { BillingClientState.ConnectionLost } + retryBillingConnection() + } + } + + private fun handleConnectionFailure(billingResult: BillingResult) { + when (billingResult.responseCode) { + BillingResponseCode.SERVICE_UNAVAILABLE -> { + trace( + tag = TAG, + message = "Billing Service is unavailable. Please check your network connection.", + type = TraceType.Silent + ) + retryBillingConnection() + } + + BillingResponseCode.SERVICE_DISCONNECTED -> { + trace( + tag = TAG, + message = "Billing Service disconnected. Retrying...", + type = TraceType.Silent + ) + retryBillingConnection() + } + + BillingResponseCode.BILLING_UNAVAILABLE -> { + trace( + tag = TAG, + message = "Billing is not available on this device. Ensure Play Store is installed.", + type = TraceType.Error + ) + } + + BillingResponseCode.ITEM_UNAVAILABLE -> { + trace( + tag = TAG, + message = "Requested item is not available.", + type = TraceType.Error + ) + } + + BillingResponseCode.ERROR -> { + trace( + tag = TAG, + message = "An unknown error occurred with billing: ${billingResult.debugMessage}", + type = TraceType.Error + ) + retryBillingConnection() + } + + BillingResponseCode.USER_CANCELED -> { + trace( + tag = TAG, + message = "User canceled the purchase flow.", + type = TraceType.Silent + ) + } + + else -> { + trace( + tag = TAG, + message = "Unhandled billing response: ${billingResult.responseCode}, ${billingResult.debugMessage}", + type = TraceType.Error + ) + retryBillingConnection() + } + } + } + + private fun retryBillingConnection() { + if (retryAttempt < MAX_RETRY_ATTEMPTS) { + val delayMillis = baseDelayMillis * (2.0.pow(retryAttempt)).toLong() + + Handler(Looper.getMainLooper()).postDelayed({ + retryAttempt++ + connect() + }, delayMillis) + + trace( + tag = TAG, + message = "Retrying connection: Attempt $retryAttempt after ${delayMillis}ms", + type = TraceType.Silent + ) + } else { + trace( + tag = TAG, + message = "Max retry attempts reached. Could not connect to billing service.", + type = TraceType.Error + ) + } + } + + private fun printLog(message: String) = println("GPBC $message") +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/billing/Products.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/billing/Products.kt new file mode 100644 index 000000000..3c573d75b --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/billing/Products.kt @@ -0,0 +1,5 @@ +package xyz.flipchat.services.billing + +enum class IapProduct(internal val productId: String, internal val isConsumable: Boolean) { + CreateAccount("com.flipchat.iap.createaccount", true) +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/ChatIdentifier.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/ChatIdentifier.kt new file mode 100644 index 000000000..250b0059a --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/ChatIdentifier.kt @@ -0,0 +1,8 @@ +package xyz.flipchat.services.data + +import com.getcode.model.ID + +sealed interface ChatIdentifier { + data class Id(val roomId: ID): ChatIdentifier + data class RoomNumber(val number: Long): ChatIdentifier +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/Member.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/Member.kt new file mode 100644 index 000000000..a4967b365 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/Member.kt @@ -0,0 +1,22 @@ +package xyz.flipchat.services.data + +import com.getcode.model.ID +import com.getcode.model.chat.Pointer +import kotlinx.serialization.Serializable + +@Serializable +data class Member( + val id: ID, + val isSelf: Boolean, + val isModerator: Boolean, + val isMuted: Boolean, + val isSpectator: Boolean, + val identity: MemberIdentity?, + val pointers: List, +) + +@Serializable +data class MemberIdentity( + val displayName: String, + val imageUrl: String?, +) \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/PaymentTarget.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/PaymentTarget.kt new file mode 100644 index 000000000..8c9c4a807 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/PaymentTarget.kt @@ -0,0 +1,7 @@ +package xyz.flipchat.services.data + +import com.getcode.model.ID + +sealed interface PaymentTarget { + data class User(val id: ID): PaymentTarget +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/Room.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/Room.kt new file mode 100644 index 000000000..889daebf7 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/Room.kt @@ -0,0 +1,71 @@ +package xyz.flipchat.services.data + +import com.getcode.model.ID +import com.getcode.model.Kin +import com.getcode.model.chat.ChatType +import com.getcode.utils.serializer.KinQuarksSerializer +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +@Serializable +data class RoomWithMemberCount( + val room: Room, + val members: Int +) + +data class RoomWithMembers( + val room: Room, + val members: List +) + +@Serializable +data class Room( + val id: ID, + val type: ChatType, + private val _title: String?, + val ownerId: ID, + val roomNumber: Long, + private val canDisablePush: Boolean, + private val isPushEnabled: Boolean, + private val unread: Int, + private val moreUnread: Boolean, + @Serializable(with = KinQuarksSerializer::class) + val messagingFee: Kin, + private val lastActive: Long?, + val isOpen: Boolean, +) { + val title: String? + get() { + val providedTitle = _title + if (providedTitle != null) { + return providedTitle + } + return null + } + + val imageData: String? + get() { + // TODO: + return null + } + + val unreadCount: Int + get() { + return unread + } + + val hasMoreUnread: Boolean + get() { + return moreUnread + } + + val canMute: Boolean + get() = canDisablePush + + val isMuted: Boolean + get() = !isPushEnabled + + val lastActivity: Instant? + get() = lastActive?.let { Instant.fromEpochMilliseconds(it) } +} + diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/StartChatRequestType.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/StartChatRequestType.kt new file mode 100644 index 000000000..1d13606d8 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/StartChatRequestType.kt @@ -0,0 +1,12 @@ +package xyz.flipchat.services.data + +import com.getcode.model.ID + +interface StartChatRequestType { + data class TwoWay(val recipient: ID) : StartChatRequestType + data class Group( + val title: String? = null, + val recipients: List = emptyList(), + val paymentId: ID + ) : StartChatRequestType +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/metadata/JoinChatPaymentMetadata.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/metadata/JoinChatPaymentMetadata.kt new file mode 100644 index 000000000..671c23370 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/metadata/JoinChatPaymentMetadata.kt @@ -0,0 +1,32 @@ +package xyz.flipchat.services.data.metadata + +import com.codeinc.flipchat.gen.chat.v1.ChatService +import com.getcode.model.ID +import kotlinx.serialization.Serializable +import xyz.flipchat.services.internal.network.extensions.toChatId +import xyz.flipchat.services.internal.network.extensions.toUserId + +@Serializable +data class JoinChatPaymentMetadata( + val userId: ID, + val chatId: ID, +) { + companion object { + fun unerase(payload: ByteArray): JoinChatPaymentMetadata { + val proto = ChatService.JoinChatPaymentMetadata.parseFrom(payload) + return JoinChatPaymentMetadata( + chatId = proto.chatId.value.toList(), + userId = proto.userId.value.toList(), + ) + } + } +} + +// TODO: make this somehow generic +fun JoinChatPaymentMetadata.erased(): ByteArray = ChatService.JoinChatPaymentMetadata.newBuilder() + .setChatId(chatId.toChatId()) + .setUserId(userId.toUserId()) + .build().toByteArray() + +val JoinChatPaymentMetadata.typeUrl: String + get() = "type.googleapis.com/flipchat.chat.v1.JoinChatPaymentMetadata" \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/metadata/SendMessageAsListenerPaymentMetadata.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/metadata/SendMessageAsListenerPaymentMetadata.kt new file mode 100644 index 000000000..15ba33ed0 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/metadata/SendMessageAsListenerPaymentMetadata.kt @@ -0,0 +1,32 @@ +package xyz.flipchat.services.data.metadata + +import com.codeinc.flipchat.gen.messaging.v1.MessagingService +import com.getcode.model.ID +import kotlinx.serialization.Serializable +import xyz.flipchat.services.internal.network.extensions.toChatId +import xyz.flipchat.services.internal.network.extensions.toUserId + +@Serializable +data class SendMessageAsListenerPaymentMetadata( + val userId: ID, + val chatId: ID, +) { + companion object { + fun unerase(payload: ByteArray): SendMessageAsListenerPaymentMetadata { + val proto = MessagingService.SendMessageAsListenerPaymentMetadata.parseFrom(payload) + return SendMessageAsListenerPaymentMetadata( + chatId = proto.chatId.value.toList(), + userId = proto.userId.value.toList(), + ) + } + } +} + +// TODO: make this somehow generic +fun SendMessageAsListenerPaymentMetadata.erased(): ByteArray = MessagingService.SendMessageAsListenerPaymentMetadata.newBuilder() + .setChatId(chatId.toChatId()) + .setUserId(userId.toUserId()) + .build().toByteArray() + +val SendMessageAsListenerPaymentMetadata.typeUrl: String + get() = "type.googleapis.com/flipchat.messaging.v1.SendMessageAsListenerPaymentMetadata" \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/metadata/SendTipMessagePaymentMetadata.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/metadata/SendTipMessagePaymentMetadata.kt new file mode 100644 index 000000000..83bbbe779 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/metadata/SendTipMessagePaymentMetadata.kt @@ -0,0 +1,35 @@ +package xyz.flipchat.services.data.metadata + +import com.codeinc.flipchat.gen.messaging.v1.MessagingService +import com.getcode.model.ID +import kotlinx.serialization.Serializable +import xyz.flipchat.services.internal.network.extensions.toChatId +import xyz.flipchat.services.internal.network.extensions.toMessageId +import xyz.flipchat.services.internal.network.extensions.toUserId + +@Serializable +data class SendTipMessagePaymentMetadata( + val chatId: ID, + val messageId: ID, + val tipperId: ID, +) { + companion object { + fun unerase(payload: ByteArray): SendTipMessagePaymentMetadata { + val proto = MessagingService.SendTipMessagePaymentMetadata.parseFrom(payload) + return SendTipMessagePaymentMetadata( + chatId = proto.chatId.value.toList(), + tipperId = proto.tipperId.value.toList(), + messageId = proto.messageId.value.toList(), + ) + } + } +} + +fun SendTipMessagePaymentMetadata.erased(): ByteArray = MessagingService.SendTipMessagePaymentMetadata.newBuilder() + .setChatId(chatId.toChatId()) + .setMessageId(messageId.toMessageId()) + .setTipperId(tipperId.toUserId()) + .build().toByteArray() + +val SendTipMessagePaymentMetadata.typeUrl: String + get() = "type.googleapis.com/flipchat.messaging.v1.SendTipMessagePaymentMetadata" \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/metadata/StartGroupChatPaymentMetadata.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/metadata/StartGroupChatPaymentMetadata.kt new file mode 100644 index 000000000..eec02d86b --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/data/metadata/StartGroupChatPaymentMetadata.kt @@ -0,0 +1,27 @@ +package xyz.flipchat.services.data.metadata + +import com.codeinc.flipchat.gen.chat.v1.ChatService +import com.getcode.model.ID +import kotlinx.serialization.Serializable +import xyz.flipchat.services.internal.network.extensions.toUserId + +@Serializable +data class StartGroupChatPaymentMetadata( + val userId: ID, +) { + companion object { + fun unerase(payload: ByteArray): StartGroupChatPaymentMetadata { + val proto = ChatService.StartGroupChatPaymentMetadata.parseFrom(payload) + return StartGroupChatPaymentMetadata( + userId = proto.userId.value.toList(), + ) + } + } +} + +fun StartGroupChatPaymentMetadata.erased(): ByteArray = ChatService.StartGroupChatPaymentMetadata.newBuilder() + .setUserId(userId.toUserId()) + .build().toByteArray() + +val StartGroupChatPaymentMetadata.typeUrl: String + get() = "type.googleapis.com/flipchat.chat.v1.StartGroupChatPaymentMetadata" \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/mapper/ConversationMessageMapper.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/mapper/ConversationMessageMapper.kt new file mode 100644 index 000000000..a3b0f8eaa --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/mapper/ConversationMessageMapper.kt @@ -0,0 +1,34 @@ +package xyz.flipchat.services.domain.mapper + +import xyz.flipchat.services.domain.model.chat.ConversationMessage +import com.getcode.model.ID +import com.getcode.services.mapper.Mapper +import com.getcode.model.chat.ChatMessage +import com.getcode.utils.base58 +import javax.inject.Inject + +class ConversationMessageMapper @Inject constructor() : + Mapper, ConversationMessage> { + override fun map(from: Pair): ConversationMessage { + val (conversationId, message) = from + + val content = message.contents.first() + + return ConversationMessage( + idBase58 = message.id.base58, + conversationIdBase58 = conversationId.base58, + senderIdBase58 = message.senderId.base58, + dateMillis = message.dateMillis, + type = content.kind, + content = content.content, + sentOffStage = message.wasSentOffStage, + // deletions happen as a by-product of a received message with delete content type + deleted = false, + deletedByBase58 = null, + // replies happen as a by-product of a received message with reply content type + inReplyToBase58 = null, + // approvals (or rejections) happen as a by-product of a received message with review content type + isApproved = null, + ) + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/mapper/RoomConversationMapper.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/mapper/RoomConversationMapper.kt new file mode 100644 index 000000000..8c18b982d --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/mapper/RoomConversationMapper.kt @@ -0,0 +1,26 @@ +package xyz.flipchat.services.domain.mapper + +import xyz.flipchat.services.data.Room +import xyz.flipchat.services.domain.model.chat.Conversation +import com.getcode.services.mapper.Mapper +import com.getcode.utils.base58 +import javax.inject.Inject + +class RoomConversationMapper @Inject constructor() : Mapper { + override fun map(from: Room): Conversation { + return Conversation( + idBase58 = from.id.base58, + ownerIdBase58 = from.ownerId.base58, + title = from.title.orEmpty(), + imageUri = from.imageData, + unreadCount = from.unreadCount, + hasMoreUnread = from.hasMoreUnread, + isMuted = from.isMuted, + canMute = from.canMute, + roomNumber = from.roomNumber, + messagingFee = from.messagingFee.quarks, + lastActivity = from.lastActivity?.toEpochMilliseconds(), + isOpen = from.isOpen + ) + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/Conversation.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/Conversation.kt new file mode 100644 index 000000000..3999d3845 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/Conversation.kt @@ -0,0 +1,123 @@ +package xyz.flipchat.services.domain.model.chat + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +import androidx.room.Relation +import com.getcode.model.ID +import com.getcode.model.Kin +import com.getcode.model.chat.MessageContent +import com.getcode.model.chat.MessageStatus +import com.getcode.utils.serializer.KinQuarksSerializer +import com.getcode.vendor.Base58 +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +@Entity(tableName = "conversations") +data class Conversation( + @PrimaryKey + val idBase58: String, + val ownerIdBase58: String?, + val title: String, + @ColumnInfo(defaultValue = "0") + val roomNumber: Long, + val imageUri: String?, + val lastActivity: Long?, + val isMuted: Boolean, + @ColumnInfo(defaultValue = "true") + val canMute: Boolean, + val unreadCount: Int, + @ColumnInfo(name = "coverChargeQuarks") + val messagingFee: Long?, + @ColumnInfo(defaultValue = "false") + val hasMoreUnread: Boolean, + @ColumnInfo(defaultValue = "true") + val isOpen: Boolean, +) { + @Ignore + val id: ID = Base58.decode(idBase58).toList() + + @Ignore + val ownerId: ID? = ownerIdBase58?.let { Base58.decode(it).toList() } + + @Ignore + @Serializable(with = KinQuarksSerializer::class) + val coverCharge: Kin = messagingFee?.let { Kin.fromQuarks(messagingFee) } ?: Kin.fromQuarks(0) +} + +@Serializable +data class ConversationWithMembersAndLastPointers( + @Embedded val conversation: Conversation, + @Relation( + parentColumn = "idBase58", + entityColumn = "conversationIdBase58" + ) + val members: List, + @Relation( + parentColumn = "idBase58", + entityColumn = "conversationIdBase58", + entity = ConversationPointerCrossRef::class, + ) + val pointersCrossRef: List, +) { + val pointers: Map + get() { + return pointersCrossRef + .associateBy { it.status } + .mapKeys { it.value.messageId } + .mapValues { it.value.status } + } +} + +data class ConversationWithMembers( + @Embedded val conversation: Conversation, + @Relation( + parentColumn = "idBase58", + entityColumn = "conversationIdBase58" + ) + val members: List, +) + + +data class ConversationWithMembersAndLastMessage( + @Embedded val conversation: Conversation, + @Relation( + parentColumn = "idBase58", + entityColumn = "conversationIdBase58" + ) + val members: List, + @Relation( + parentColumn = "idBase58", + entityColumn = "conversationIdBase58", + entity = ConversationMessage::class, + projection = ["idBase58", "dateMillis", "senderIdBase58", "type", "content", "tipCount", "isApproved", "sentOffStage"] + ) + val lastMessage: ConversationMessage? +) { + val id: ID + get() = conversation.id + val title: String + get() = conversation.title + val imageUri: String? + get() = conversation.imageUri + val lastActivity: Long? + get() = conversation.lastActivity + val isMuted: Boolean + get() = conversation.isMuted + val canChangeMuteState: Boolean + get() = conversation.canMute + val unreadCount: Int + get() = conversation.unreadCount + val hasMoreUnread: Boolean + get() = conversation.hasMoreUnread + + val ownerId: ID? + get() = conversation.ownerId + + val messageContentPreview: MessageContent? + get() = lastMessage?.let { MessageContent.fromData(it.type, it.content, false) } +} + diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/ConversationMember.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/ConversationMember.kt new file mode 100644 index 000000000..edf6ae098 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/ConversationMember.kt @@ -0,0 +1,39 @@ +package xyz.flipchat.services.domain.model.chat + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.Index +import com.getcode.model.ID +import com.getcode.vendor.Base58 +import kotlinx.serialization.Serializable + +@Serializable +@Entity( + tableName = "members", + primaryKeys = ["memberIdBase58", "conversationIdBase58"], + indices = [ + Index(value = ["memberIdBase58"]), + Index(value = ["conversationIdBase58"]) + ] +) +data class ConversationMember( + val memberIdBase58: String, + val conversationIdBase58: String, + val memberName: String?, + val imageUri: String?, + @ColumnInfo(defaultValue = "false") + val isHost: Boolean, // isModerator + @ColumnInfo(defaultValue = "false") + val isMuted: Boolean, + @ColumnInfo(defaultValue = "false") + val isFullMember: Boolean, + @ColumnInfo(defaultValue = "false") + val isBlocked: Boolean +) { + @Ignore + val id: ID = Base58.decode(memberIdBase58).toList() + + @Ignore + val conversationId: ID = Base58.decode(conversationIdBase58).toList() +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/ConversationMessage.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/ConversationMessage.kt new file mode 100644 index 000000000..11951afea --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/ConversationMessage.kt @@ -0,0 +1,119 @@ +package xyz.flipchat.services.domain.model.chat + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.Index +import androidx.room.Junction +import androidx.room.PrimaryKey +import androidx.room.Relation +import com.getcode.model.ID +import com.getcode.model.KinAmount +import com.getcode.model.chat.MessageContent +import com.getcode.vendor.Base58 +import kotlinx.serialization.Serializable + +@Serializable +@Entity( + tableName = "messages", + indices = [ + Index(value = ["conversationIdBase58"]), // For filtering by conversation ID + Index(value = ["senderIdBase58"]), // For joining on sender ID + Index(value = ["dateMillis"]), // For ordering by date + ] +) +data class ConversationMessage( + @PrimaryKey + val idBase58: String, + val conversationIdBase58: String, + @ColumnInfo(defaultValue = "") + val senderIdBase58: String, + val dateMillis: Long, + private val deleted: Boolean?, + private val deletedByBase58: String? = null, + val inReplyToBase58: String? = null, + @ColumnInfo(defaultValue = "false") + val sentOffStage: Boolean = false, + val isApproved: Boolean? = null, + @ColumnInfo(defaultValue = "0") + val tipCount: Int = 0, + @ColumnInfo(defaultValue = "1") + val type: Int, + @ColumnInfo(defaultValue = "") + val content: String, +) { + fun getDeletedByBase58(): String? = deletedByBase58 + + @Ignore + val id: ID = Base58.decode(idBase58).toList() + + @Ignore + val conversationId: ID = Base58.decode(conversationIdBase58).toList() + + @Ignore + val senderId: ID = Base58.decode(senderIdBase58).toList() + + @Ignore + val deletedBy: ID? = deletedByBase58?.let { Base58.decode(deletedByBase58).toList() } + + @Ignore + val isDeleted: Boolean = deleted == true + + @Ignore + val inReplyTo: ID? = inReplyToBase58?.let { Base58.decode(it).toList() } +} + +data class ConversationMessageWithMemberAndReply( + @Embedded val message: ConversationMessage, + @Relation( + parentColumn = "senderIdBase58", + entityColumn = "memberIdBase58", + entity = ConversationMember::class, + ) + val member: ConversationMember?, + @Relation( + parentColumn = "inReplyToBase58", + entityColumn = "idBase58", + entity = ConversationMessage::class, + ) + val inReplyTo: ConversationMessageWithMemberAndContent? = null, + @Relation( + parentColumn = "idBase58", + entityColumn = "messageIdBase58", + entity = ConversationMessageTip::class, + ) + val tips: List +) + +data class ConversationMessageWithMemberAndContent( + @Embedded val message: ConversationMessage, + @Relation( + parentColumn = "senderIdBase58", + entityColumn = "memberIdBase58", + entity = ConversationMember::class, + ) + val member: ConversationMember?, +) { + @Ignore + var contentEntity: MessageContent = MessageContent.Unknown(false) +} + +data class InflatedConversationMessage( + val pageIndex: Int = 0, // tracking for [PagingSource] refresh eky + val message: ConversationMessage, + val member: ConversationMember?, + val content: MessageContent, + val reply: ConversationMessageWithMemberAndContent?, + val tips: List +) + +data class MessageTipInfo( + @Embedded val tip: ConversationMessageTip, + @Relation( + parentColumn = "tipperIdBase58", + entityColumn = "memberIdBase58", + entity = ConversationMember::class, + ) + val tipper: ConversationMember? +) \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/ConversationMessageTip.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/ConversationMessageTip.kt new file mode 100644 index 000000000..62885292f --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/ConversationMessageTip.kt @@ -0,0 +1,37 @@ +package xyz.flipchat.services.domain.model.chat + +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.Index +import androidx.room.PrimaryKey +import com.getcode.model.ID +import com.getcode.model.KinAmount +import com.getcode.vendor.Base58 +import kotlinx.serialization.Serializable + +@Serializable +@Entity( + tableName = "tips", + indices = [ + Index(value = ["messageIdBase58"]), + ], +) +data class ConversationMessageTip( + @PrimaryKey + val idBase58: String, + val messageIdBase58: String, + val amount: Long, + val tipperIdBase58: String, +) { + @Ignore + val id: ID = Base58.decode(idBase58).toList() + + @Ignore + val messageId: ID = Base58.decode(messageIdBase58).toList() + + @Ignore + val tipperId: ID = Base58.decode(tipperIdBase58).toList() + + @Ignore + val kin: KinAmount = KinAmount.fromQuarks(amount) +} diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/Pointers.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/Pointers.kt new file mode 100644 index 000000000..b22bdb68d --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/Pointers.kt @@ -0,0 +1,26 @@ +package xyz.flipchat.services.domain.model.chat + +import androidx.room.Entity +import androidx.room.Ignore +import com.getcode.model.ID +import com.getcode.model.chat.MessageStatus +import com.getcode.vendor.Base58 +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import java.util.UUID + + +@Serializable +@Entity(tableName = "conversation_pointers", primaryKeys = ["conversationIdBase58", "status"]) +data class ConversationPointerCrossRef( + val conversationIdBase58: String, + val messageIdString: String, + val status: MessageStatus, +) { + @Ignore + val conversationId: ID = Base58.decode(conversationIdBase58).toList() + + @Ignore + @Transient + val messageId: UUID = UUID.fromString(messageIdString) +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/StreamMemberUpdate.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/StreamMemberUpdate.kt new file mode 100644 index 000000000..e5c6ca07b --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/StreamMemberUpdate.kt @@ -0,0 +1,68 @@ +package xyz.flipchat.services.domain.model.chat + +import com.codeinc.flipchat.gen.chat.v1.ChatService +import com.getcode.model.ID +import com.getcode.model.uuid +import xyz.flipchat.services.data.Member + +sealed interface StreamMemberUpdate { + // Refreshes the state of the entire chat membership + data class Refresh(val members: List): StreamMemberUpdate { + override fun toString(): String { + return "FULL REFRESH:: ${members.count()}" + } + } + // Refreshes the state of an individual member in the chat + data class IndividualRefresh(val member: Member): StreamMemberUpdate { + override fun toString(): String { + return "INDIVIDUAL REFRESH:: ${member.id.uuid}" + } + } + // Member joined the chat via the JoinChat RPC + data class Joined(val member: Member): StreamMemberUpdate { + override fun toString(): String { + return "JOINED:: ${member.id.uuid}" + } + } + // Member left the chat via the LeaveChat RPC + data class Left(val memberId: ID): StreamMemberUpdate { + override fun toString(): String { + return "LEFT:: ${memberId.uuid}" + } + } + // Member was removed from the chat via the RemoveUser RPC + data class Removed(val memberId: ID, val removedBy: ID): StreamMemberUpdate { + override fun toString(): String { + return "REMOVED:: ${memberId.uuid} by ${removedBy.uuid}" + } + } + // Member was muted in the chat via the MuteUser RPC + data class Muted(val memberId: ID, val mutedBy: ID): StreamMemberUpdate { + override fun toString(): String { + return "MUTED:: ${memberId.uuid} by ${mutedBy.uuid}" + } + } + + // Member was promoted in the chat via the PromoteUser RPC + data class Promoted(val memberId: ID, val by: ID): StreamMemberUpdate { + override fun toString(): String { + return "PROMOTED:: ${memberId.uuid} by ${by.uuid}" + } + } + + // Member was demoted in the chat via the DemoteUser RPC + data class Demoted(val memberId: ID, val by: ID): StreamMemberUpdate { + override fun toString(): String { + return "DEMOTED:: ${memberId.uuid} by ${by.uuid}" + } + } +} + +sealed interface StreamMetadataUpdate { + data class Refresh(val metadata: ChatService.Metadata): StreamMetadataUpdate + data class UnreadCount(val numUnread: Int, val hasMoreUnread: Boolean): StreamMetadataUpdate + data class DisplayName(val name: String): StreamMetadataUpdate + data class MessagingFee(val amount: Long): StreamMetadataUpdate + data class LastActivity(val timestamp: Long): StreamMetadataUpdate + data class OpenStatusChanged(val nowOpen: Boolean): StreamMetadataUpdate +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/db/ChatUpdate.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/db/ChatUpdate.kt new file mode 100644 index 000000000..be945bc3a --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/chat/db/ChatUpdate.kt @@ -0,0 +1,34 @@ +package xyz.flipchat.services.domain.model.chat.db + +import com.getcode.model.ID +import xyz.flipchat.services.domain.model.chat.Conversation +import xyz.flipchat.services.domain.model.chat.ConversationMember +import xyz.flipchat.services.domain.model.chat.ConversationMessage + +data class ChatUpdate( + val metadata: List, + val message: ConversationMessage?, + val members: List, +) + +sealed interface ConversationMemberUpdate { + data class FullRefresh(val members: List): ConversationMemberUpdate + data class IndividualRefresh(val member: ConversationMember): ConversationMemberUpdate + data class Joined(val member: ConversationMember): ConversationMemberUpdate + data class Left(val roomId: ID, val memberId: ID): ConversationMemberUpdate + data class Removed(val roomId: ID, val memberId: ID, val removedBy: ID): ConversationMemberUpdate + data class Muted(val roomId: ID, val memberId: ID, val mutedBy: ID): ConversationMemberUpdate + data class Promoted(val roomId: ID, val memberId: ID, val by: ID): ConversationMemberUpdate + data class Demoted(val roomId: ID, val memberId: ID, val by: ID): ConversationMemberUpdate +} + +sealed interface ConversationUpdate { + data class Refresh(val conversation: Conversation): ConversationUpdate + data class UnreadCount(val roomId: ID, val numUnread: Int, val hasMoreUnread: Boolean): ConversationUpdate + data class DisplayName(val roomId: ID, val name: String): ConversationUpdate + data class CoverCharge(val roomId: ID, val amount: Long): ConversationUpdate + data class LastActivity(val roomId: ID, val timestamp: Long): ConversationUpdate + data class OpenStatus(val roomId: ID, val nowOpen: Boolean): ConversationUpdate +} + + diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/profile/UserProfile.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/profile/UserProfile.kt new file mode 100644 index 000000000..749b7bb8f --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/profile/UserProfile.kt @@ -0,0 +1,5 @@ +package xyz.flipchat.services.domain.model.profile + +data class UserProfile( + val displayName: String +) diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/query/QueryOptions.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/query/QueryOptions.kt new file mode 100644 index 000000000..f40f700f9 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/domain/model/query/QueryOptions.kt @@ -0,0 +1,9 @@ +package xyz.flipchat.services.domain.model.query + +typealias PagingToken = List + +data class QueryOptions( + val limit: Int = 100, + val token: PagingToken? = null, + val descending: Boolean = true +) diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/extensions/Conversation.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/extensions/Conversation.kt new file mode 100644 index 000000000..cd63f5738 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/extensions/Conversation.kt @@ -0,0 +1,36 @@ +package xyz.flipchat.services.extensions + +import com.getcode.util.resources.ResourceHelper +import xyz.flipchat.services.chat.R +import xyz.flipchat.services.data.Room +import xyz.flipchat.services.domain.model.chat.Conversation + +fun Conversation.titleOrFallback(resources: ResourceHelper): String { + return if (title.isEmpty()) { + resources.getString( + R.string.title_implicitRoomTitleWithoutPrefix, + roomNumber + ) + } else { + resources.getString( + R.string.title_explicitRoomTitle, + roomNumber, + title + ) + } +} + +fun Room.titleOrFallback(resources: ResourceHelper): String { + return if (title == null) { + resources.getString( + R.string.title_implicitRoomTitleWithoutPrefix, + roomNumber + ) + } else { + resources.getString( + R.string.title_explicitRoomTitle, + roomNumber, + title!! + ) + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/annotations/ChatManagedChannel.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/annotations/ChatManagedChannel.kt new file mode 100644 index 000000000..beacaca71 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/annotations/ChatManagedChannel.kt @@ -0,0 +1,13 @@ +package xyz.flipchat.services.internal.annotations + +import javax.inject.Qualifier + +@Qualifier +@Target( + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER, + AnnotationTarget.VALUE_PARAMETER, + AnnotationTarget.FIELD +) +annotation class ChatManagedChannel \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/ChatMessageMapper.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/ChatMessageMapper.kt new file mode 100644 index 000000000..0af257d70 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/ChatMessageMapper.kt @@ -0,0 +1,36 @@ +package xyz.flipchat.services.internal.data.mapper + +import com.codeinc.flipchat.gen.messaging.v1.Model +import com.getcode.model.ID +import com.getcode.model.chat.ChatMessage +import com.getcode.model.chat.MessageContent +import com.getcode.model.uuid +import com.getcode.services.mapper.Mapper +import com.getcode.utils.timestamp +import xyz.flipchat.services.internal.protomapping.invoke +import javax.inject.Inject + +class ChatMessageMapper @Inject constructor(): Mapper, ChatMessage> { + override fun map(from: Pair): ChatMessage { + val (selfId, message) = from + val messageId = message.messageId.value.toByteArray().toList() + val messageSenderId = message.senderId.value.toByteArray().toList() + val isFromSelf = selfId == messageSenderId + + val timestamp = messageId.uuid?.timestamp ?: (message.ts.seconds * 1_000L) + return ChatMessage( + id = messageId, + senderId = messageSenderId, + isFromSelf = isFromSelf, + dateMillis = timestamp, + wasSentOffStage = message.wasSenderOffStage, + contents = message.contentList.mapNotNull { + MessageContent.invoke( + it, + messageSenderId, + isFromSelf + ) + }, + ) + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/ConversationMemberMapper.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/ConversationMemberMapper.kt new file mode 100644 index 000000000..16034e0b7 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/ConversationMemberMapper.kt @@ -0,0 +1,24 @@ +package xyz.flipchat.services.internal.data.mapper + +import com.getcode.model.ID +import com.getcode.services.mapper.Mapper +import com.getcode.utils.base58 +import xyz.flipchat.services.data.Member +import xyz.flipchat.services.domain.model.chat.ConversationMember +import javax.inject.Inject + +class ConversationMemberMapper @Inject constructor(): Mapper, ConversationMember> { + override fun map(from: Pair): ConversationMember { + val (conversationId, member) = from + return ConversationMember( + memberIdBase58 = member.id.base58, + conversationIdBase58 = conversationId.base58, + memberName = member.identity?.displayName, + imageUri = member.identity?.imageUrl, + isHost = member.isModerator, + isMuted = member.isMuted, + isFullMember = !member.isSpectator, + isBlocked = false // local set only right now + ) + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/LastMessageMapper.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/LastMessageMapper.kt new file mode 100644 index 000000000..88bfa6feb --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/LastMessageMapper.kt @@ -0,0 +1,33 @@ +package xyz.flipchat.services.internal.data.mapper + +import com.codeinc.flipchat.gen.messaging.v1.Model +import com.getcode.model.ID +import com.getcode.model.chat.ChatMessage +import com.getcode.model.chat.MessageContent +import com.getcode.services.mapper.Mapper +import xyz.flipchat.services.internal.protomapping.invoke +import javax.inject.Inject + +class LastMessageMapper @Inject constructor( +): Mapper, ChatMessage> { + override fun map(from: Pair): ChatMessage { + val (userId, message) = from + val messageId = message.messageId.value.toByteArray().toList() + val messageSenderId = message.senderId.value.toByteArray().toList() + val isFromSelf = userId == messageSenderId + return ChatMessage( + id = messageId, + senderId = messageSenderId, + isFromSelf = isFromSelf, + wasSentOffStage = message.wasSenderOffStage, + dateMillis = message.ts.seconds * 1_000L, + contents = message.contentList.mapNotNull { + MessageContent.invoke( + it, + messageSenderId, + isFromSelf + ) + }, + ) + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/MemberIdentityMapper.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/MemberIdentityMapper.kt new file mode 100644 index 000000000..a1d35f1aa --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/MemberIdentityMapper.kt @@ -0,0 +1,15 @@ +package xyz.flipchat.services.internal.data.mapper + +import com.codeinc.flipchat.gen.chat.v1.ChatService +import com.getcode.services.mapper.Mapper +import xyz.flipchat.services.data.MemberIdentity +import javax.inject.Inject + +class MemberIdentityMapper @Inject constructor(): Mapper { + override fun map(from: ChatService.MemberIdentity): MemberIdentity { + return MemberIdentity( + displayName = from.displayName, + imageUrl = from.profilePicUrl + ) + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/MemberMapper.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/MemberMapper.kt new file mode 100644 index 000000000..83a973b50 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/MemberMapper.kt @@ -0,0 +1,24 @@ +package xyz.flipchat.services.internal.data.mapper + +import com.codeinc.flipchat.gen.chat.v1.ChatService +import com.getcode.services.mapper.Mapper +import xyz.flipchat.services.data.Member +import javax.inject.Inject + +class MemberMapper @Inject constructor( + private val identityMapper: MemberIdentityMapper, + private val pointerModelMapper: PointerModelMapper, +): Mapper { + override fun map(from: ChatService.Member): Member { + val memberId = from.userId.value.toByteArray().toList() + return Member( + id = memberId, + isSelf = from.isSelf, + isModerator = from.hasModeratorPermission, + isMuted = from.isMuted, + isSpectator = !from.hasSendPermission, + identity = identityMapper.map(from.identity), + pointers = from.pointersList.mapNotNull { pointerModelMapper.map(memberId to it) } + ) + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/MemberUpdateMapper.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/MemberUpdateMapper.kt new file mode 100644 index 000000000..de9e3bbaf --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/MemberUpdateMapper.kt @@ -0,0 +1,68 @@ +package xyz.flipchat.services.internal.data.mapper + +import com.codeinc.flipchat.gen.chat.v1.ChatService.MemberUpdate as ApiMemberUpdate +import com.getcode.services.mapper.Mapper +import xyz.flipchat.services.domain.model.chat.StreamMemberUpdate +import javax.inject.Inject + + +class MemberUpdateMapper @Inject constructor( + private val memberMapper: MemberMapper, +) : Mapper { + override fun map(from: ApiMemberUpdate): StreamMemberUpdate? { + val result = when (from.kindCase) { + ApiMemberUpdate.KindCase.FULL_REFRESH -> { + StreamMemberUpdate.Refresh(members = from.fullRefresh.membersList.map { + memberMapper.map( + it + ) + }) + } + + ApiMemberUpdate.KindCase.LEFT -> { + StreamMemberUpdate.Left(memberId = from.left.member.value.toList()) + } + + ApiMemberUpdate.KindCase.JOINED -> { + StreamMemberUpdate.Joined(member = memberMapper.map(from.joined.member)) + } + + ApiMemberUpdate.KindCase.INDIVIDUAL_REFRESH -> { + StreamMemberUpdate.IndividualRefresh(member = memberMapper.map(from.joined.member)) + } + + ApiMemberUpdate.KindCase.MUTED -> { + StreamMemberUpdate.Muted( + memberId = from.muted.member.value.toList(), + mutedBy = from.muted.mutedBy.value.toList() + ) + } + + ApiMemberUpdate.KindCase.REMOVED -> { + StreamMemberUpdate.Removed( + memberId = from.muted.member.value.toList(), + removedBy = from.muted.mutedBy.value.toList() + ) + } + + ApiMemberUpdate.KindCase.PROMOTED -> { + StreamMemberUpdate.Promoted( + memberId = from.promoted.member.value.toList(), + by = from.promoted.promotedBy.value.toList(), + ) + } + + ApiMemberUpdate.KindCase.DEMOTED -> { + StreamMemberUpdate.Demoted( + memberId = from.demoted.member.value.toList(), + by = from.demoted.demotedBy.value.toList(), + ) + } + + ApiMemberUpdate.KindCase.KIND_NOT_SET -> null + else -> null + } + + return result + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/MetadataRoomMapper.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/MetadataRoomMapper.kt new file mode 100644 index 000000000..f7b3e7b6b --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/MetadataRoomMapper.kt @@ -0,0 +1,33 @@ +package xyz.flipchat.services.internal.data.mapper + +import com.codeinc.flipchat.gen.chat.v1.ChatService +import com.codeinc.flipchat.gen.chat.v1.lastActivityOrNull +import com.codeinc.flipchat.gen.chat.v1.openStatusOrNull +import com.getcode.model.Kin +import com.getcode.model.chat.ChatType +import com.getcode.services.mapper.Mapper +import xyz.flipchat.services.data.Room +import javax.inject.Inject + +class MetadataRoomMapper @Inject constructor( +): Mapper { + override fun map(from: ChatService.Metadata): Room { + return Room( + id = from.chatId.value.toByteArray().toList(), + ownerId = from.owner.value.toByteArray().toList(), + _title = from.displayName.nullIfEmpty(), + roomNumber = from.roomNumber, + type = ChatType.entries[from.type.ordinal], + unread = from.numUnread, + moreUnread = from.hasMoreUnread, + canDisablePush = from.canDisablePush, + isPushEnabled = from.isPushEnabled, + messagingFee = Kin.fromQuarks(from.messagingFee.quarks.ifZeroOrElse(200) { it / 100_000 }), + lastActive = from.lastActivityOrNull?.seconds?.times(1_000), + isOpen = from.openStatusOrNull?.isCurrentlyOpen ?: true + ) + } +} + +internal fun Long.ifZeroOrElse(other: Long, block: (Long) -> Long) = takeIf { it > 0 }?.let(block) ?: other +fun String?.nullIfEmpty() = if (this?.isEmpty() == true) null else this \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/MetadataUpdateMapper.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/MetadataUpdateMapper.kt new file mode 100644 index 000000000..64b274c6d --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/MetadataUpdateMapper.kt @@ -0,0 +1,22 @@ +package xyz.flipchat.services.internal.data.mapper + +import com.codeinc.flipchat.gen.chat.v1.ChatService +import com.codeinc.flipchat.gen.chat.v1.ChatService.MetadataUpdate as ApiMetadataUpdate +import com.getcode.services.mapper.Mapper +import xyz.flipchat.services.domain.model.chat.StreamMetadataUpdate +import javax.inject.Inject + +class MetadataUpdateMapper @Inject constructor(): Mapper { + override fun map(from: ApiMetadataUpdate): StreamMetadataUpdate? { + return when (from.kindCase) { + ChatService.MetadataUpdate.KindCase.FULL_REFRESH -> StreamMetadataUpdate.Refresh(from.fullRefresh.metadata) + ChatService.MetadataUpdate.KindCase.UNREAD_COUNT_CHANGED -> StreamMetadataUpdate.UnreadCount(from.unreadCountChanged.numUnread, from.unreadCountChanged.hasMoreUnread) + ChatService.MetadataUpdate.KindCase.DISPLAY_NAME_CHANGED -> StreamMetadataUpdate.DisplayName(from.displayNameChanged.newDisplayName) + ChatService.MetadataUpdate.KindCase.MESSAGING_FEE_CHANGED -> StreamMetadataUpdate.MessagingFee(from.messagingFeeChanged.newMessagingFee.quarks.ifZeroOrElse(200) { it / 100_000 }) + ChatService.MetadataUpdate.KindCase.LAST_ACTIVITY_CHANGED -> StreamMetadataUpdate.LastActivity(from.lastActivityChanged.newLastActivity.seconds * 1000L) + ChatService.MetadataUpdate.KindCase.OPEN_STATUS_CHANGED -> StreamMetadataUpdate.OpenStatusChanged(from.openStatusChanged.newOpenStatus.isCurrentlyOpen) + ChatService.MetadataUpdate.KindCase.KIND_NOT_SET -> null + else -> null + } + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/PointerMapper.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/PointerMapper.kt new file mode 100644 index 000000000..51e840bc5 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/PointerMapper.kt @@ -0,0 +1,31 @@ +package xyz.flipchat.services.internal.data.mapper + +import com.codeinc.flipchat.gen.messaging.v1.Model +import com.getcode.model.ID +import com.getcode.model.chat.MessageStatus +import com.getcode.model.chat.Pointer +import com.getcode.model.uuid +import com.getcode.services.mapper.Mapper +import javax.inject.Inject + +class PointerModelMapper @Inject constructor(): Mapper, Pointer?> { + + override fun map(from: Pair): Pointer? { + val (memberId, proto) = from + val status = when (proto.type) { + Model.Pointer.Type.SENT -> MessageStatus.Sent + Model.Pointer.Type.DELIVERED -> MessageStatus.Delivered + Model.Pointer.Type.READ -> MessageStatus.Read + else -> MessageStatus.Unknown + } + + val messageId = proto.value.value.toByteArray().toList().uuid ?: return null + + return when (status) { + MessageStatus.Sent -> Pointer.Sent(memberId, messageId) + MessageStatus.Delivered -> Pointer.Delivered(memberId, messageId) + MessageStatus.Read -> Pointer.Read(memberId, messageId) + MessageStatus.Unknown -> Pointer.Unknown(memberId) + } + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/ProfileMapper.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/ProfileMapper.kt new file mode 100644 index 000000000..c864836ef --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/ProfileMapper.kt @@ -0,0 +1,12 @@ +package xyz.flipchat.services.internal.data.mapper + +import com.codeinc.flipchat.gen.profile.v1.Model +import com.getcode.services.mapper.Mapper +import xyz.flipchat.services.domain.model.profile.UserProfile +import javax.inject.Inject + +class ProfileMapper @Inject constructor(): Mapper { + override fun map(from: Model.UserProfile): UserProfile { + return UserProfile(displayName = from.displayName) + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/RoomWithMemberCountMapper.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/RoomWithMemberCountMapper.kt new file mode 100644 index 000000000..9f0783ed8 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/RoomWithMemberCountMapper.kt @@ -0,0 +1,17 @@ +package xyz.flipchat.services.internal.data.mapper + +import com.getcode.services.mapper.Mapper +import xyz.flipchat.services.data.RoomWithMemberCount +import xyz.flipchat.services.internal.network.chat.GetOrJoinChatResponse +import javax.inject.Inject + +class RoomWithMemberCountMapper @Inject constructor( + private val roomMapper: MetadataRoomMapper, +) : Mapper { + override fun map(from: GetOrJoinChatResponse): RoomWithMemberCount { + return RoomWithMemberCount( + room = roomMapper.map(from.metadata), + members = from.members.count() + ) + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/RoomWithMembersMapper.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/RoomWithMembersMapper.kt new file mode 100644 index 000000000..b55418307 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/RoomWithMembersMapper.kt @@ -0,0 +1,18 @@ +package xyz.flipchat.services.internal.data.mapper + +import com.getcode.services.mapper.Mapper +import xyz.flipchat.services.data.RoomWithMembers +import xyz.flipchat.services.internal.network.chat.GetOrJoinChatResponse +import javax.inject.Inject + +class RoomWithMembersMapper @Inject constructor( + private val roomMapper: MetadataRoomMapper, + private val memberMapper: MemberMapper, +) : Mapper { + override fun map(from: GetOrJoinChatResponse): RoomWithMembers { + return RoomWithMembers( + room = roomMapper.map(from.metadata), + members = from.members.map { memberMapper.map(it) } + ) + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/StreamMetadataUpdateMapper.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/StreamMetadataUpdateMapper.kt new file mode 100644 index 000000000..211301453 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/StreamMetadataUpdateMapper.kt @@ -0,0 +1,25 @@ +package xyz.flipchat.services.internal.data.mapper + +import com.getcode.model.ID +import com.getcode.services.mapper.Mapper +import xyz.flipchat.services.domain.mapper.RoomConversationMapper +import xyz.flipchat.services.domain.model.chat.StreamMetadataUpdate +import xyz.flipchat.services.domain.model.chat.db.ConversationUpdate +import javax.inject.Inject + +class StreamMetadataUpdateMapper @Inject constructor( + private val metadataMapper: MetadataRoomMapper, + private val conversationMapper: RoomConversationMapper, +): Mapper, ConversationUpdate> { + override fun map(from: Pair): ConversationUpdate { + val (id, update) = from + return when (update) { + is StreamMetadataUpdate.MessagingFee -> ConversationUpdate.CoverCharge(id, update.amount) + is StreamMetadataUpdate.DisplayName -> ConversationUpdate.DisplayName(id, update.name) + is StreamMetadataUpdate.LastActivity -> ConversationUpdate.LastActivity(id, update.timestamp) + is StreamMetadataUpdate.Refresh -> ConversationUpdate.Refresh(conversationMapper.map(metadataMapper.map(update.metadata))) + is StreamMetadataUpdate.UnreadCount -> ConversationUpdate.UnreadCount(id, update.numUnread, update.hasMoreUnread) + is StreamMetadataUpdate.OpenStatusChanged -> ConversationUpdate.OpenStatus(id, update.nowOpen) + } + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/UserFlagsMapper.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/UserFlagsMapper.kt new file mode 100644 index 000000000..487b5d9d4 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/data/mapper/UserFlagsMapper.kt @@ -0,0 +1,19 @@ +package xyz.flipchat.services.internal.data.mapper + +import com.codeinc.flipchat.gen.account.v1.AccountService +import com.getcode.model.Kin +import com.getcode.services.mapper.Mapper +import xyz.flipchat.services.user.UserFlags +import xyz.flipchat.services.internal.network.extensions.toPublicKey +import javax.inject.Inject + +class UserFlagsMapper @Inject constructor(): Mapper { + override fun map(from: AccountService.UserFlags): UserFlags { + return UserFlags( + isStaff = from.isStaff, + isRegistered = from.isRegisteredAccount, + createCost = Kin.fromQuarks(from.startGroupFee.quarks.ifZeroOrElse(200) { it / 100_000 }), + feeDestination = from.feeDestination.toPublicKey() + ) + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/db/ConversationDao.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/db/ConversationDao.kt new file mode 100644 index 000000000..abae9b15a --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/db/ConversationDao.kt @@ -0,0 +1,226 @@ +package xyz.flipchat.services.internal.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.RewriteQueriesToDropUnusedColumns +import androidx.room.Transaction +import com.getcode.model.ID +import com.getcode.model.Kin +import com.getcode.utils.base58 +import kotlinx.coroutines.flow.Flow +import xyz.flipchat.services.domain.model.chat.Conversation +import xyz.flipchat.services.domain.model.chat.ConversationMessage +import xyz.flipchat.services.domain.model.chat.ConversationWithMembers +import xyz.flipchat.services.domain.model.chat.ConversationWithMembersAndLastMessage +import xyz.flipchat.services.domain.model.chat.ConversationWithMembersAndLastPointers + +@Dao +interface ConversationDao { + + @Query("SELECT * FROM conversations") + suspend fun getConversations(): List + suspend fun getConversationIds(): List { + return getConversations().map { it.id } + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertConversations(vararg conversation: Conversation) + + @RewriteQueriesToDropUnusedColumns + @Query( + """ + SELECT * FROM conversations + WHERE roomNumber > 0 + ORDER BY lastActivity DESC + LIMIT :limit OFFSET :offset + """ + ) + suspend fun getPagedConversationsWithMembers(limit: Int, offset: Int): List + + suspend fun getPagedConversations(limit: Int, offset: Int): List { + return getPagedConversationsWithMembers(limit, offset) + .map { + val lastMessage = getLatestMessage(it.conversation.id) + ConversationWithMembersAndLastMessage( + conversation = it.conversation, + members = it.members, + lastMessage = lastMessage + ) + } + } + + @Query( + """ + WITH prioritized_messages AS ( + SELECT *, + CASE + WHEN type IN (1, 8) THEN 1 + ELSE 2 + END AS priority + FROM messages + WHERE conversationIdBase58 = :id + ) + SELECT * + FROM prioritized_messages + ORDER BY priority, dateMillis DESC + LIMIT 1; + """ + ) + suspend fun getLatestMessage(id: String): ConversationMessage? + suspend fun getLatestMessage(id: ID): ConversationMessage? { + return getLatestMessage(id.base58) + } + + @RewriteQueriesToDropUnusedColumns + @Transaction + @Query( + """ + SELECT * FROM conversations AS c + LEFT JOIN members AS m ON c.idBase58 = m.conversationIdBase58 + LEFT JOIN conversation_pointers AS p ON c.idBase58 = p.conversationIdBase58 + WHERE c.idBase58 = :id + """ + ) + fun observeConversation(id: String): Flow + + fun observeConversation(id: ID): Flow { + return observeConversation(id.base58) + } + + @RewriteQueriesToDropUnusedColumns + @Query("SELECT * FROM conversations WHERE idBase58 = :id") + suspend fun findConversation(id: String): ConversationWithMembersAndLastPointers? + + suspend fun findConversation(id: ID): ConversationWithMembersAndLastPointers? { + return findConversation(id.base58) + } + + @RewriteQueriesToDropUnusedColumns + @Query("SELECT * FROM conversations WHERE idBase58 = :id") + suspend fun findConversationRaw(id: String): Conversation? + + @RewriteQueriesToDropUnusedColumns + @Query("SELECT * FROM conversations WHERE roomNumber = :number") + suspend fun findConversationRaw(number: Long): Conversation? + + suspend fun findConversationRaw(id: ID): Conversation? { + return findConversationRaw(id.base58) + } + + @Query("SELECT * FROM conversations") + suspend fun queryConversations(): List + + @Query(""" + DELETE FROM conversations + WHERE idBase58 NOT IN ( + SELECT conversationIdBase58 + FROM members + WHERE idBase58 = :id +) + """) + suspend fun removeConversationsWhereNotMember(id: String) + suspend fun removeConversationsWhereNotMember(id: ID) { + removeConversationsWhereNotMember(id.base58) + } + + @Query(""" + SELECT EXISTS ( + SELECT 1 FROM conversations + WHERE idBase58 NOT IN ( + SELECT conversationIdBase58 + FROM members + WHERE idBase58 = :userId + ) AND idBase58 = :conversationId + ) + """) + suspend fun isUserMemberIn(userId: String, conversationId: String): Boolean + suspend fun isUserMemberIn(userId: ID, conversationId: ID): Boolean { + return isUserMemberIn(userId.base58, conversationId.base58) + } + + @Delete + fun deleteConversation(conversation: Conversation) + + @Query("DELETE FROM conversations WHERE idBase58 = :id") + suspend fun deleteConversationById(id: String) + + suspend fun deleteConversationById(id: ID) { + deleteConversationById(id.base58) + } + + suspend fun setDisplayName(id: String, displayName: String) { + val conversation = findConversation(id)?.conversation ?: return + upsertConversations(conversation.copy(title = displayName)) + } + + suspend fun setDisplayName(id: ID, displayName: String) { + setDisplayName(id.base58, displayName) + } + + @Query("DELETE FROM conversations WHERE idBase58 NOT IN (:chatIds)") + suspend fun purgeConversationsNotInByString(chatIds: List) + suspend fun purgeConversationsNotIn(chatIds: List) { + purgeConversationsNotInByString(chatIds.map { it.base58 }) + } + + @Query("DELETE FROM conversations") + fun clearConversations() + + @Query("SELECT unreadCount FROM conversations WHERE idBase58 = :conversationId") + suspend fun getUnreadCount(conversationId: String): Int? + suspend fun getUnreadCount(conversationId: ID): Int? { + return getUnreadCount(conversationId.base58) + } + + suspend fun resetUnreadCount(conversationId: String) { + val conversation = findConversation(conversationId)?.conversation ?: return + upsertConversations(conversation.copy(unreadCount = 0)) + } + + suspend fun resetUnreadCount(conversationId: ID) { + resetUnreadCount(conversationId.base58) + } + + @Query("UPDATE conversations SET coverChargeQuarks = :quarks WHERE idBase58 = :conversationId") + suspend fun updateMessagingFee(conversationId: String, quarks: Long) + suspend fun updateMessagingFee(conversationId: ID, quarks: Long) { + updateMessagingFee(conversationId.base58, quarks) + } + suspend fun updateMessagingFee(conversationId: ID, kin: Kin) { + updateMessagingFee(conversationId.base58, kin.toKinTruncatingLong()) + } + + @Query("UPDATE conversations SET isOpen = 1 WHERE idBase58 = :conversationId") + suspend fun enableChatInRoom(conversationId: String) + suspend fun enableChatInRoom(conversationId: ID) { + enableChatInRoom(conversationId.base58) + } + + @Query("UPDATE conversations SET isOpen = 0 WHERE idBase58 = :conversationId") + suspend fun disableChatInRoom(conversationId: String) + suspend fun disableChatInRoom(conversationId: ID) { + disableChatInRoom(conversationId.base58) + } + + @Query("UPDATE conversations SET isMuted = 1 WHERE idBase58 = :conversationId") + suspend fun muteChat(conversationId: String) + suspend fun muteChat(conversationId: ID) { + muteChat(conversationId.base58) + } + + @Query("UPDATE conversations SET isMuted = 0 WHERE idBase58 = :conversationId") + suspend fun unmuteChat(conversationId: String) + suspend fun unmuteChat(conversationId: ID) { + unmuteChat(conversationId.base58) + } + + @Transaction + @Query(""" + SELECT * FROM conversations + WHERE idBase58 = :conversationId + """) + suspend fun getConversationWithMembersAndLastMessage(conversationId: String): ConversationWithMembersAndLastMessage? +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/db/ConversationMemberDao.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/db/ConversationMemberDao.kt new file mode 100644 index 000000000..2983fbf3f --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/db/ConversationMemberDao.kt @@ -0,0 +1,103 @@ +package xyz.flipchat.services.internal.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.RewriteQueriesToDropUnusedColumns +import androidx.room.Transaction +import com.getcode.model.ID +import com.getcode.utils.base58 +import kotlinx.coroutines.flow.Flow +import xyz.flipchat.services.domain.model.chat.Conversation +import xyz.flipchat.services.domain.model.chat.ConversationMember +import xyz.flipchat.services.domain.model.chat.ConversationWithMembersAndLastPointers + +@Dao +interface ConversationMemberDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertMembers(vararg members: ConversationMember) + + @RewriteQueriesToDropUnusedColumns + @Transaction + @Query( + """ + SELECT * FROM members + WHERE conversationIdBase58 = :id + """ + ) + fun observeMembersIn(id: String): Flow> + + fun observeMembersIn(id: ID): Flow> { + return observeMembersIn(id.base58) + } + + @Query("SELECT * FROM members WHERE memberIdBase58 = :memberId AND conversationIdBase58 = :conversationId") + suspend fun getMemberIn(memberId: String, conversationId: String): ConversationMember? + suspend fun getMemberIn(memberId: ID, conversationId: ID): ConversationMember? { + return getMemberIn(memberId.base58, conversationId.base58) + } + + @Query("DELETE FROM members WHERE conversationIdBase58 = :conversationId") + suspend fun removeMembersFrom(conversationId: String) + suspend fun removeMembersFrom(conversationId: ID) { + removeMembersFrom(conversationId.base58) + } + + @Query("DELETE FROM members WHERE memberIdBase58 = :memberId AND conversationIdBase58 = :conversationId") + suspend fun removeMemberFromConversation(memberId: String, conversationId: String) + suspend fun removeMemberFromConversation(memberId: ID, conversationId: ID) { + removeMemberFromConversation(memberId.base58, conversationId.base58) + } + + @Query("DELETE FROM members WHERE memberIdBase58 NOT IN (:memberIds) AND conversationIdBase58 = :conversationId") + suspend fun purgeMembersNotIn(conversationId: String, memberIds: List) + + suspend fun purgeMembersNotIn(conversationId: ID, memberIds: List) { + purgeMembersNotIn(conversationId.base58, memberIds) + } + + suspend fun refreshMembers(conversationId: ID, members: List) { + removeMembersFrom(conversationId) + upsertMembers(*members.toTypedArray()) + } + + @Query("UPDATE members SET isMuted = 1 WHERE conversationIdBase58 = :conversationId AND memberIdBase58 = :memberId") + suspend fun muteMember(conversationId: String, memberId: String) + suspend fun muteMember(conversationId: ID, memberId: ID) { + muteMember(conversationId.base58, memberId.base58) + } + + @Query("UPDATE members SET isMuted = 0 WHERE conversationIdBase58 = :conversationId AND memberIdBase58 = :memberId") + suspend fun unmuteMember(conversationId: String, memberId: String) + suspend fun unmuteMember(conversationId: ID, memberId: ID) { + unmuteMember(conversationId.base58, memberId.base58) + } + + @Query("UPDATE members SET isFullMember = 1 WHERE conversationIdBase58 = :conversationId AND memberIdBase58 = :memberId") + suspend fun promoteMember(conversationId: String, memberId: String) + suspend fun promoteMember(conversationId: ID, memberId: ID) { + promoteMember(conversationId.base58, memberId.base58) + } + + @Query("UPDATE members SET isFullMember = 0 WHERE conversationIdBase58 = :conversationId AND memberIdBase58 = :memberId") + suspend fun demoteMember(conversationId: String, memberId: String) + suspend fun demoteMember(conversationId: ID, memberId: ID) { + demoteMember(conversationId.base58, memberId.base58) + } + + @Query("UPDATE members SET isBlocked = 1 WHERE memberIdBase58 = :memberId") + suspend fun blockMember(memberId: String) + suspend fun blockMember(memberId: ID) { + blockMember(memberId.base58) + } + + @Query("UPDATE members SET isBlocked = 0 WHERE memberIdBase58 = :memberId") + suspend fun unblockMember(memberId: String) + suspend fun unblockMember(memberId: ID) { + unblockMember(memberId.base58) + } + + @Query("DELETE FROM members") + fun clearMembers() +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/db/ConversationMessageDao.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/db/ConversationMessageDao.kt new file mode 100644 index 000000000..fa1f60726 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/db/ConversationMessageDao.kt @@ -0,0 +1,298 @@ +package xyz.flipchat.services.internal.db + +import androidx.compose.ui.util.fastDistinctBy +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.RewriteQueriesToDropUnusedColumns +import androidx.room.Transaction +import com.getcode.model.ID +import com.getcode.model.chat.MessageContent +import com.getcode.model.uuid +import com.getcode.utils.base58 +import xyz.flipchat.services.domain.model.chat.ConversationMember +import xyz.flipchat.services.domain.model.chat.ConversationMessage +import xyz.flipchat.services.domain.model.chat.ConversationMessageTip +import xyz.flipchat.services.domain.model.chat.ConversationMessageWithMemberAndContent +import xyz.flipchat.services.domain.model.chat.ConversationMessageWithMemberAndReply +import xyz.flipchat.services.domain.model.chat.InflatedConversationMessage + +@Dao +interface ConversationMessageDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertMessagesInternal(vararg message: ConversationMessage) + + @Transaction + suspend fun upsertMessages(messages: List, selfID: ID?) { + upsertMessagesInternal(*messages.toTypedArray()) + + val deletes = messages + .mapNotNull { m -> + MessageContent.fromData( + type = m.type, + content = m.content, + isFromSelf = m.senderId == selfID, + ) as? MessageContent.DeletedMessage + } + + val replies = messages + .mapNotNull { m -> + val originalMessageId = (MessageContent.fromData( + type = m.type, + content = m.content, + isFromSelf = m.senderId == selfID, + ) as? MessageContent.Reply)?.originalMessageId ?: return@mapNotNull null + + m.id to originalMessageId + } + + val tips = messages + .mapNotNull { m -> + val tipContent = MessageContent.fromData( + type = m.type, + content = m.content, + isFromSelf = m.senderId == selfID, + ) as? MessageContent.MessageTip ?: return@mapNotNull null + + m.id to tipContent + } + + // first review for a message in the stream wins + // so we sort and distinct by the [originalMessageId] and only change [isApproved] if + // not previously set + val reviews: List = messages + .mapNotNull { m -> + MessageContent.fromData( + type = m.type, + content = m.content, + isFromSelf = m.senderId == selfID, + ) as? MessageContent.MessageInReview ?: return@mapNotNull null + }.sortedBy { it.originalMessageId.uuid } + .fastDistinctBy { it.originalMessageId } + .filterNot { hasBeenReviewed(it.originalMessageId) } + + deletes.onEach { + markDeleted(it.originalMessageId, it.messageDeleter) + } + + replies.onEach { (messageId, inReplyTo) -> + connectReply(messageId, inReplyTo) + } + + tips.onEach { + addTip(tipMessageId = it.first, tipContent = it.second) + } + + reviews.onEach { review -> + if (review.isApproved) { + approve(review.originalMessageId) + } else { + reject(review.originalMessageId) + } + } + } + + @RewriteQueriesToDropUnusedColumns + @Transaction + @Query(""" + SELECT + messages.idBase58 AS idBase58, + messages.senderIdBase58 AS senderIdBase58, + messages.dateMillis AS dateMillis, + messages.conversationIdBase58 AS conversationIdBase58, + messages.type AS type, + messages.tipCount AS tipCount, + messages.deleted AS deleted, + messages.deletedByBase58 AS deletedByBase58, + messages.inReplyToBase58 AS inReplyToBase58, + messages.isApproved AS isApproved, + messages.sentOffStage AS sentOffStage, + messages.content AS content, + members.memberIdBase58 AS memberIdBase58, + members.memberName AS memberName, + members.isHost AS isHost, + members.imageUri AS imageUri, + members.isBlocked AS isBlocked, + members.isFullMember AS isFullMember, + members.isMuted AS isMuted + FROM messages + + LEFT JOIN members ON messages.senderIdBase58 = members.memberIdBase58 + AND messages.conversationIdBase58 = members.conversationIdBase58 + AND members.conversationIdBase58 = :id + LEFT JOIN tips ON messages.idBase58 = tips.messageIdBase58 + -- RawText, Announcements, Replies, and Actionable Announcements -- + WHERE messages.conversationIdBase58 = :id AND type IN (1, 4, 8, 12) + GROUP BY messages.idBase58 + -- ID is a base58 encoded v7 UUID which is guaranteed lexigraphically in order -- + ORDER BY messages.idBase58 DESC + LIMIT :limit OFFSET :offset +""") + suspend fun getPagedMessages(id: String, limit: Int, offset: Int): List + suspend fun getPagedMessages(id: ID, limit: Int, offset: Int): List { + return getPagedMessages(id.base58, limit, offset) + } + + @Query("SELECT * FROM members WHERE memberIdBase58 = :memberId AND conversationIdBase58 = :conversationId") + suspend fun getMemberInternal(conversationId: String, memberId: String): ConversationMember? + suspend fun getMemberInternal(conversationId: ID, memberId: ID): ConversationMember? { + return getMemberInternal(conversationId.base58, memberId.base58) + } + + suspend fun getPagedMessagesWithDetails(id: ID, limit: Int, offset: Int, selfId: ID?): List { + val messages = getPagedMessages(id.base58, limit, offset) + + return messages.map { + val content = MessageContent.fromData(it.message.type, it.message.content, isFromSelf = selfId == it.message.senderId) + val member = getMemberInternal(id, it.message.senderId) + val replyContent = it.inReplyTo?.let { rp -> + MessageContent.fromData(rp.message.type, rp.message.content, isFromSelf = rp.message.senderId == selfId) + } ?: MessageContent.Unknown(false) + InflatedConversationMessage( + message = it.message, + member = member, + content = content, + reply = it.inReplyTo?.apply { contentEntity = replyContent }, + tips = it.tips, + ) + } + } + + @Query("SELECT COUNT(*) FROM messages WHERE conversationIdBase58 = :conversationId") + suspend fun getTotalMessageCountFor(conversationId: String): Int + suspend fun getTotalMessageCountFor(conversationId: ID): Int { + return getTotalMessageCountFor(conversationId.base58) + } + + @Query("SELECT * FROM messages WHERE conversationIdBase58 = :conversationId") + suspend fun queryMessages(conversationId: String): List + + suspend fun queryMessages(conversationId: ID): List { + return queryMessages(conversationId.base58) + } + + @Query("SELECT * FROM messages WHERE conversationIdBase58 = :conversationId ORDER BY dateMillis DESC LIMIT 1") + suspend fun getNewestMessage(conversationId: String): ConversationMessage? + + suspend fun getNewestMessage(conversationId: ID): ConversationMessage? { + return getNewestMessage(conversationId.base58) + } + + @RewriteQueriesToDropUnusedColumns + @Transaction + @Query( + """ + SELECT messages.* + FROM messages + WHERE messages.idBase58 = :messageId + LIMIT 1 + """) + suspend fun getMessageById(messageId: String): ConversationMessageWithMemberAndReply? + + + suspend fun getMessageWithContentById(messageId: String, selfId: String?): ConversationMessageWithMemberAndContent? { + val row = getMessageById(messageId) ?: return null + val content = MessageContent.fromData(row.message.type, row.message.content, isFromSelf = row.message.senderIdBase58 == selfId) ?: return null + val member = getMemberInternal(row.message.conversationId, row.message.senderId) + return ConversationMessageWithMemberAndContent( + message = row.message, + member = member, + ).apply { + contentEntity = content + } + } + + suspend fun getMessageWithContentById(messageId: ID, selfID: ID?): ConversationMessageWithMemberAndContent? { + return getMessageWithContentById(messageId.base58, selfID?.base58) + } + + + @Query(""" + SELECT * FROM messages + WHERE type = :type AND conversationIdBase58 = :conversationId + ORDER BY dateMillis DESC + """) + suspend fun getMessagesOfTypeInConversation(conversationId: String, type: Int): List + suspend fun getMessagesOfTypeInConversation(conversationId: ID, type: Int): List { + return getMessagesOfTypeInConversation(conversationId.base58, type) + } + + + @Query("DELETE FROM messages WHERE conversationIdBase58 = :conversationId") + suspend fun removeForConversation(conversationId: String) + + suspend fun removeForConversation(conversationId: ID) { + removeForConversation(conversationId.base58) + } + + @Query("UPDATE messages SET deleted = 1, deletedByBase58 = :by WHERE idBase58 = :messageId") + suspend fun markDeleted(messageId: String, by: String) + + suspend fun markDeleted(messageId: ID, by: ID) { + markDeleted(messageId.base58, by.base58) + } + + @Query("UPDATE messages SET inReplyToBase58 = :inReplyTo WHERE idBase58 = :messageId") + suspend fun connectReply(messageId: String, inReplyTo: String) + suspend fun connectReply(messageId: ID, inReplyTo: ID) { + connectReply(messageId.base58, inReplyTo.base58) + } + + @Query("UPDATE messages SET tipCount = tipCount + 1 WHERE idBase58 = :messageId") + suspend fun incrementTipCount(messageId: String) + suspend fun incrementTipCount(messageId: ID) { + incrementTipCount(messageId.base58) + } + + @Query("SELECT isApproved FROM messages WHERE idBase58 = :messageId AND isApproved IS NOT NULL") + suspend fun hasBeenReviewed(messageId: String): Boolean + suspend fun hasBeenReviewed(messageId: ID): Boolean { + return hasBeenReviewed(messageId.base58) + } + + @Query("UPDATE messages SET isApproved = 1 WHERE idBase58 = :messageId") + suspend fun approve(messageId: String) + suspend fun approve(messageId: ID) { + approve(messageId.base58) + } + + @Query("UPDATE messages SET isApproved = 0 WHERE idBase58 = :messageId") + suspend fun reject(messageId: String) + suspend fun reject(messageId: ID) { + reject(messageId.base58) + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun addTip(vararg tip: ConversationMessageTip) + + suspend fun addTip(tipMessageId: ID, tipContent: MessageContent.MessageTip) { + val tip = ConversationMessageTip( + idBase58 = tipMessageId.base58, + messageIdBase58 = tipContent.originalMessageId.base58, + amount = tipContent.amountInQuarks, + tipperIdBase58 = tipContent.tipperId.base58 + ) + + incrementTipCount(tipContent.originalMessageId) + + addTip(tip) + } + + @Query("DELETE FROM messages WHERE conversationIdBase58 NOT IN (:chatIds)") + suspend fun purgeMessagesNotInByString(chatIds: List) + + suspend fun purgeMessagesNotIn(chatIds: List) { + purgeMessagesNotInByString(chatIds.map { it.base58 }) + } + + @Query("DELETE FROM messages") + fun clearMessages() + + @Query("DELETE FROM messages WHERE conversationIdBase58 = :chatId") + suspend fun clearMessagesForChat(chatId: String) + suspend fun clearMessagesForChat(chatId: ID) { + clearMessagesForChat(chatId.base58) + } +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/db/ConversationPointerDao.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/db/ConversationPointerDao.kt similarity index 81% rename from api/src/main/java/com/getcode/db/ConversationPointerDao.kt rename to services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/db/ConversationPointerDao.kt index b9a60acd3..e57c93244 100644 --- a/api/src/main/java/com/getcode/db/ConversationPointerDao.kt +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/db/ConversationPointerDao.kt @@ -1,16 +1,13 @@ -package com.getcode.db +package xyz.flipchat.services.internal.db import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import androidx.room.Transaction -import com.getcode.model.Conversation -import com.getcode.model.ConversationIntentIdReference -import com.getcode.model.ConversationPointerCrossRef import com.getcode.model.ID -import com.getcode.model.MessageStatus -import com.getcode.network.repository.base58 +import com.getcode.model.chat.MessageStatus +import com.getcode.utils.base58 +import xyz.flipchat.services.domain.model.chat.ConversationPointerCrossRef import java.util.UUID @Dao diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/inject/FcChatModule.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/inject/FcChatModule.kt new file mode 100644 index 000000000..7bde49a8d --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/inject/FcChatModule.kt @@ -0,0 +1,49 @@ +package xyz.flipchat.services.internal.inject + +import android.content.Context +import com.getcode.services.utils.logging.LoggingClientInterceptor +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import io.grpc.ManagedChannel +import io.grpc.android.AndroidChannelBuilder +import org.kin.sdk.base.network.api.agora.OkHttpChannelBuilderForcedTls12 +import xyz.flipchat.services.FcChatConfig +import xyz.flipchat.services.chat.BuildConfig +import xyz.flipchat.services.internal.annotations.ChatManagedChannel +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object FcChatModule { + + @Singleton + @Provides + fun providesChatServicesConfig(): FcChatConfig { + return FcChatConfig() + } + + @Singleton + @Provides + @ChatManagedChannel + fun provideManagedChannel( + @ApplicationContext context: Context, + config: FcChatConfig, + ): ManagedChannel { + return AndroidChannelBuilder + .usingBuilder(OkHttpChannelBuilderForcedTls12.forAddress(config.baseUrl, config.port)) + .context(context) + .userAgent(config.userAgent) + .keepAliveTime(config.keepAlive.inWholeMilliseconds, TimeUnit.MILLISECONDS) + .keepAliveTimeout(config.keepAliveTimeout.inWholeMilliseconds, TimeUnit.MILLISECONDS) + .apply { + if (BuildConfig.DEBUG) { + this.intercept(LoggingClientInterceptor()) + } + } + .build() + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/inject/RepositoryModule.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/inject/RepositoryModule.kt new file mode 100644 index 000000000..b63cbf4ae --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/inject/RepositoryModule.kt @@ -0,0 +1,113 @@ +package xyz.flipchat.services.internal.inject + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import xyz.flipchat.services.domain.mapper.ConversationMessageMapper +import xyz.flipchat.services.domain.mapper.RoomConversationMapper +import xyz.flipchat.services.internal.data.mapper.ChatMessageMapper +import xyz.flipchat.services.internal.data.mapper.ConversationMemberMapper +import xyz.flipchat.services.internal.data.mapper.LastMessageMapper +import xyz.flipchat.services.internal.data.mapper.MemberUpdateMapper +import xyz.flipchat.services.internal.data.mapper.MetadataRoomMapper +import xyz.flipchat.services.internal.data.mapper.MetadataUpdateMapper +import xyz.flipchat.services.internal.data.mapper.ProfileMapper +import xyz.flipchat.services.internal.data.mapper.RoomWithMembersMapper +import xyz.flipchat.services.internal.data.mapper.StreamMetadataUpdateMapper +import xyz.flipchat.services.internal.data.mapper.UserFlagsMapper +import xyz.flipchat.services.internal.network.repository.accounts.AccountRepository +import xyz.flipchat.services.internal.network.repository.accounts.RealAccountRepository +import xyz.flipchat.services.internal.network.repository.chat.ChatRepository +import xyz.flipchat.services.internal.network.repository.chat.RealChatRepository +import xyz.flipchat.services.internal.network.repository.iap.InAppPurchaseRepository +import xyz.flipchat.services.internal.network.repository.iap.RealInAppPurchaseRepository +import xyz.flipchat.services.internal.network.repository.messaging.MessagingRepository +import xyz.flipchat.services.internal.network.repository.messaging.RealMessagingRepository +import xyz.flipchat.services.internal.network.repository.profile.ProfileRepository +import xyz.flipchat.services.internal.network.repository.profile.RealProfileRepository +import xyz.flipchat.services.internal.network.repository.push.PushRepository +import xyz.flipchat.services.internal.network.repository.push.RealPushRepository +import xyz.flipchat.services.internal.network.service.AccountService +import xyz.flipchat.services.internal.network.service.ChatService +import xyz.flipchat.services.internal.network.service.MessagingService +import xyz.flipchat.services.internal.network.service.ProfileService +import xyz.flipchat.services.internal.network.service.PurchaseService +import xyz.flipchat.services.internal.network.service.PushService +import xyz.flipchat.services.user.UserManager + +@Module +@InstallIn(SingletonComponent::class) +internal object RepositoryModule { + + @Provides + internal fun providesAccountRepository( + userManager: UserManager, + service: AccountService, + userFlagsMapper: UserFlagsMapper, + ): AccountRepository = RealAccountRepository(userManager, service, userFlagsMapper) + + @Provides + internal fun provideChatRepository( + userManager: UserManager, + service: ChatService, + roomMapper: MetadataRoomMapper, + conversationMapper: RoomConversationMapper, + roomWithMembersMapper: RoomWithMembersMapper, + memberUpdateMapper: MemberUpdateMapper, + metadataUpdateMapper: MetadataUpdateMapper, + streamMetadataUpdateMapper: StreamMetadataUpdateMapper, + conversationMemberMapper: ConversationMemberMapper, + messageMapper: LastMessageMapper, + messageWithContentMapper: ConversationMessageMapper, + ): ChatRepository = RealChatRepository( + userManager = userManager, + service = service, + roomMapper = roomMapper, + roomWithMembersMapper = roomWithMembersMapper, + conversationMapper = conversationMapper, + memberUpdateMapper = memberUpdateMapper, + conversationMemberMapper = conversationMemberMapper, + lastMessageMapper = messageMapper, + messageMapper = messageWithContentMapper, + metadataUpdateMapper = metadataUpdateMapper, + streamMetadataUpdateMapper = streamMetadataUpdateMapper + ) + + @Provides + internal fun providesInAppPurchaseRepository( + userManager: UserManager, + service: PurchaseService + ): InAppPurchaseRepository = RealInAppPurchaseRepository(userManager, service) + + @Provides + internal fun providesMessagingRepository( + userManager: UserManager, + service: MessagingService, + messageMapper: ChatMessageMapper, + lastMessageMapper: LastMessageMapper, + messageWithContentMapper: ConversationMessageMapper + ): MessagingRepository = RealMessagingRepository( + userManager = userManager, + service = service, + chatMessageMapper = messageMapper, + lastMessageMapper = lastMessageMapper, + messageMapper = messageWithContentMapper + ) + + @Provides + internal fun providesProfileRepository( + userManager: UserManager, + service: ProfileService, + profileMapper: ProfileMapper, + ): ProfileRepository = RealProfileRepository( + userManager = userManager, + service = service, + profileMapper = profileMapper + ) + + @Provides + internal fun providesPushRepository( + service: PushService + ): PushRepository = RealPushRepository(service = service) +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/api/AccountApi.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/api/AccountApi.kt new file mode 100644 index 000000000..e67097e45 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/api/AccountApi.kt @@ -0,0 +1,134 @@ +package xyz.flipchat.services.internal.network.api + +import com.codeinc.flipchat.gen.account.v1.AccountGrpc +import com.codeinc.flipchat.gen.account.v1.AccountService +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.model.ID +import com.getcode.services.network.core.GrpcApi +import com.google.protobuf.Timestamp +import io.grpc.ManagedChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import xyz.flipchat.services.data.PaymentTarget +import xyz.flipchat.services.internal.annotations.ChatManagedChannel +import xyz.flipchat.services.internal.network.extensions.asPublicKey +import xyz.flipchat.services.internal.network.extensions.toUserId +import xyz.flipchat.services.internal.network.utils.authenticate +import xyz.flipchat.services.internal.network.utils.sign +import javax.inject.Inject + +class AccountApi @Inject constructor( + @ChatManagedChannel + managedChannel: ManagedChannel, +) : GrpcApi(managedChannel) { + + private val api = AccountGrpc.newStub(managedChannel).withWaitForReady() + + /** + * Register registers a new user, bound to the provided PublicKey. + * If the PublicKey is already in use, the previous user account is returned. + */ + fun register(owner: KeyPair, displayName: String?): Flow { + val builder = AccountService.RegisterRequest.newBuilder() + .setPublicKey(owner.asPublicKey()) + + if (displayName != null) { + builder.setDisplayName(displayName) + } + + val request = builder.apply { setSignature(sign(owner)) }.build() + + return api::register + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + fun login(owner: KeyPair): Flow { + val request = AccountService.LoginRequest.newBuilder() + .setTimestamp(Timestamp.newBuilder().setSeconds(System.currentTimeMillis() / 1_000)) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::login + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + /** + * Authorizes an additional PublicKey to an account. + */ + fun authorizePublicKey( + userId: ID, + owner: KeyPair, + newKeyPair: KeyPair, + ): Flow { + + val request = AccountService.AuthorizePublicKeyRequest.newBuilder() + .setPublicKey(newKeyPair.asPublicKey()) + .setUserId(userId.toUserId()) + .apply { setAuth(authenticate(owner)) } + .apply { setSignature(sign(newKeyPair)) } + .build() + + return api::authorizePublicKey + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + /** + * Revokes a public key from an account. + * + * There must be at least one public key per account. For now, any authorized public key + * may revoke another public key, but this may change in the future. + */ + fun revokePublicKey( + userId: ID, + owner: KeyPair, + keypair: KeyPair, + ): Flow { + + val request = AccountService.RevokePublicKeyRequest.newBuilder() + .setPublicKey(keypair.asPublicKey()) + .setUserId(userId.toUserId()) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::revokePublicKey + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + /** + * Gets the payment destination for a target + */ + fun getPaymentDestination( + target: PaymentTarget + ): Flow { + val builder = AccountService.GetPaymentDestinationRequest.newBuilder() + + when (target) { + is PaymentTarget.User -> builder.setUserId(target.id.toUserId()) + } + + val request = builder.build() + + return api::getPaymentDestination + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + fun getUserFlags( + userId: ID, + owner: KeyPair, + ): Flow { + val request = AccountService.GetUserFlagsRequest.newBuilder() + .setUserId(userId.toUserId()) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::getUserFlags + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/api/ChatApi.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/api/ChatApi.kt new file mode 100644 index 000000000..5a7af3130 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/api/ChatApi.kt @@ -0,0 +1,403 @@ +package xyz.flipchat.services.internal.network.api + +import com.codeinc.flipchat.gen.chat.v1.ChatGrpc +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.model.ID +import com.getcode.model.KinAmount +import com.getcode.services.network.core.GrpcApi +import io.grpc.ManagedChannel +import io.grpc.stub.StreamObserver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import xyz.flipchat.services.data.ChatIdentifier +import xyz.flipchat.services.data.StartChatRequestType +import xyz.flipchat.services.domain.model.query.QueryOptions +import xyz.flipchat.services.internal.annotations.ChatManagedChannel +import xyz.flipchat.services.internal.network.extensions.toChatId +import xyz.flipchat.services.internal.network.extensions.toIntentId +import xyz.flipchat.services.internal.network.extensions.toMessageId +import xyz.flipchat.services.internal.network.extensions.toPagingToken +import xyz.flipchat.services.internal.network.extensions.toPaymentAmount +import xyz.flipchat.services.internal.network.extensions.toProto +import xyz.flipchat.services.internal.network.extensions.toUserId +import xyz.flipchat.services.internal.network.utils.authenticate +import javax.inject.Inject +import com.codeinc.flipchat.gen.chat.v1.ChatService as ChatServiceRpc + +class ChatApi @Inject constructor( + @ChatManagedChannel + managedChannel: ManagedChannel +) : GrpcApi(managedChannel) { + private val api = ChatGrpc.newStub(managedChannel).withWaitForReady() + + // StartChat starts a chat. The RPC call is idempotent and will use existing + // chats whenever applicable within the context of message routing. + fun startChat( + owner: KeyPair, + type: StartChatRequestType, + ): Flow { + val builder = ChatServiceRpc.StartChatRequest.newBuilder() + + with(builder) { + when (type) { + is StartChatRequestType.TwoWay -> setTwoWayChat( + ChatServiceRpc.StartChatRequest.StartTwoWayChatParameters.newBuilder() + .setOtherUserId(type.recipient.toUserId()) + ) + + is StartChatRequestType.Group -> { + val groupBuilder = + ChatServiceRpc.StartChatRequest.StartGroupChatParameters.newBuilder() + with(groupBuilder) { + type.recipients + .map { it.toUserId() } + .onEachIndexed { index, user -> setUsers(index, user) } + } + + groupBuilder.setPaymentIntent(type.paymentId.toIntentId()) + + setGroupChat(groupBuilder) + } + + else -> {} + } + } + + val request = builder.apply { setAuth(authenticate(owner)) }.build() + + return api::startChat + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + // GetChats gets the set of chats for an owner account using a paged API. + // This RPC is aware of all identities tied to the owner account. + fun getChats( + owner: KeyPair, + queryOptions: QueryOptions, + ): Flow { + val request = ChatServiceRpc.GetChatsRequest.newBuilder() + .setQueryOptions(queryOptions.toProto()) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::getChats + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + // GetChat returns the metadata for a specific chat. + fun getChat( + owner: KeyPair, + identifier: ChatIdentifier, + ): Flow { + + val builder = ChatServiceRpc.GetChatRequest.newBuilder() + when (identifier) { + is ChatIdentifier.Id -> builder.setChatId(identifier.roomId.toChatId()) + is ChatIdentifier.RoomNumber -> builder.setRoomNumber(identifier.number) + } + + builder.apply { setAuth(authenticate(owner)) } + + val request = builder.build() + + return api::getChat + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + // JoinChat joins a given chat. + fun joinChat( + owner: KeyPair, + identifier: ChatIdentifier, + paymentId: ID?, + ): Flow { + val builder = ChatServiceRpc.JoinChatRequest.newBuilder() + + if (paymentId != null) { + builder.setWithoutSendPermission(false) + builder.setPaymentIntent(paymentId.toIntentId()) + } else { + builder.setWithoutSendPermission(true) + } + + when (identifier) { + is ChatIdentifier.Id -> builder.setChatId(identifier.roomId.toChatId()) + is ChatIdentifier.RoomNumber -> builder.setRoomId(identifier.number) + } + + builder.apply { setAuth(authenticate(owner)) } + + val request = builder.build() + + return api::joinChat + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + // LeaveChat leaves a given chat. + fun leaveChat( + owner: KeyPair, + chatId: ID, + ): Flow { + val request = ChatServiceRpc.LeaveChatRequest.newBuilder() + .setChatId(chatId.toChatId()) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::leaveChat + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + // SetDisplayName sets a chat's display name. If the display name isn't allowed, + // then a set of alternate suggestions may be provided + fun setDisplayName( + owner: KeyPair, + chatId: ID, + displayName: String, + ): Flow { + val request = ChatServiceRpc.SetDisplayNameRequest.newBuilder() + .setChatId(chatId.toChatId()) + .setDisplayName(displayName) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::setDisplayName + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + // MuteChat mutes a chat and disables push notifications + fun muteChat( + owner: KeyPair, + chatId: ID, + ): Flow { + val request = ChatServiceRpc.MuteChatRequest.newBuilder() + .setChatId(chatId.toChatId()) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::muteChat + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + // UnmuteChat unmutes a chat and enables push notifications + fun unmuteChat( + owner: KeyPair, + chatId: ID, + ): Flow { + val request = ChatServiceRpc.UnmuteChatRequest.newBuilder() + .setChatId(chatId.toChatId()) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::unmuteChat + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + // SetCoverCharge sets a chat's cover charge + // + // Deprecated: Use SetMessagingFee instead + fun setCoverCharge( + owner: KeyPair, + chatId: ID, + amount: KinAmount, + ): Flow { + val request = ChatServiceRpc.SetCoverChargeRequest.newBuilder() + .setChatId(chatId.toChatId()) + .setCoverCharge(amount.toPaymentAmount()) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::setCoverCharge + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + // SetMessagingFee sets a chat's messaging fee + fun setMessagingFee( + owner: KeyPair, + chatId: ID, + amount: KinAmount, + ): Flow { + val request = ChatServiceRpc.SetMessagingFeeRequest.newBuilder() + .setChatId(chatId.toChatId()) + .setMessagingFee(amount.toPaymentAmount()) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::setMessagingFee + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + // RemoveUser removes a user from a chat + fun removeUser( + owner: KeyPair, + chatId: ID, + userId: ID, + ): Flow { + val request = ChatServiceRpc.RemoveUserRequest.newBuilder() + .setChatId(chatId.toChatId()) + .setUserId(userId.toUserId()) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::removeUser + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + // ReportUser reports a user for a given message + fun reportUser( + owner: KeyPair, + userId: ID, + messageId: ID, + ): Flow { + val request = ChatServiceRpc.ReportUserRequest.newBuilder() + .setUserId(userId.toUserId()) + .setMessageId(messageId.toMessageId()) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::reportUser + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + // MuteUser mutes a user in the chat and removes their ability to send messages + fun muteUser( + owner: KeyPair, + chatId: ID, + userId: ID, + ): Flow { + val request = ChatServiceRpc.MuteUserRequest.newBuilder() + .setChatId(chatId.toChatId()) + .setUserId(userId.toUserId()) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::muteUser + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + // GetMemberUpdates gets member updates for a given chat + fun getMemberUpdates( + owner: KeyPair, + chatId: ID, + afterMember: ID?, + ): Flow { + val builder = ChatServiceRpc.GetMemberUpdatesRequest.newBuilder() + .setChatId(chatId.toChatId()) + + if (afterMember != null) { + builder.setPagingToken(afterMember.toPagingToken()) + } + + builder.apply { setAuth(authenticate(owner)) } + + val request = builder.build() + + + return api::getMemberUpdates + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + // PromoteUser promotes a user to an elevated permission state + fun promoteUser( + owner: KeyPair, + chatId: ID, + userId: ID, + enableSendPermission: Boolean = true, + ): Flow { + val request = ChatServiceRpc.PromoteUserRequest.newBuilder() + .setChatId(chatId.toChatId()) + .setUserId(userId.toUserId()) + .setEnableSendPermission(enableSendPermission) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::promoteUser + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + // DemoteUser demotes a user to a lower permission state + fun demoteUser( + owner: KeyPair, + chatId: ID, + userId: ID, + disableSendPermission: Boolean = true, + ): Flow { + val request = ChatServiceRpc.DemoteUserRequest.newBuilder() + .setChatId(chatId.toChatId()) + .setUserId(userId.toUserId()) + .setDisableSendPermission(disableSendPermission) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::demoteUser + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + // OpenChat opens a chat up for messaging across all members + fun openChat( + owner: KeyPair, + chatId: ID, + ): Flow { + val request = ChatServiceRpc.OpenChatRequest.newBuilder() + .setChatId(chatId.toChatId()) + .apply { setAuth(authenticate(owner)) } + .build() + + + return api::openChat + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + // CloseChat closes a chat up for messaging to just the chat owner + fun closeChat( + owner: KeyPair, + chatId: ID, + ): Flow { + val request = ChatServiceRpc.CloseChatRequest.newBuilder() + .setChatId(chatId.toChatId()) + .apply { setAuth(authenticate(owner)) } + .build() + + + return api::closeChat + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + // StreamChatEvents streams all chat events for the requesting user. + // + // Chat events will include any update to a chat, including: + // 1. Metadata changes. + // 2. Membership changes. + // 3. Latest messages. + // + // The server will optionally filter out some events depending on load + // and chat type. For example, Broadcast chats will not receive latest + // messages. + // + // Clients should use GetMessages to backfill in any historical messages + // for a chat. It should be sufficient to rely on ChatEvents for some types + // of chats, but using StreamMessages provides a guarentee of message events + // for all chats. + fun streamEvents( + observer: StreamObserver + ): StreamObserver? { + return api.streamChatEvents(observer) + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/api/MessagingApi.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/api/MessagingApi.kt new file mode 100644 index 000000000..fa93efc33 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/api/MessagingApi.kt @@ -0,0 +1,178 @@ +package xyz.flipchat.services.internal.network.api + +import com.codeinc.flipchat.gen.messaging.v1.MessagingGrpc +import com.codeinc.flipchat.gen.messaging.v1.MessagingService +import com.codeinc.flipchat.gen.messaging.v1.Model +import com.codeinc.flipchat.gen.messaging.v1.Model.Pointer +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.model.ID +import com.getcode.model.KinAmount +import com.getcode.model.chat.MessageStatus +import com.getcode.services.model.chat.OutgoingMessageContent +import com.getcode.services.network.core.GrpcApi +import com.getcode.utils.toByteString +import io.grpc.ManagedChannel +import io.grpc.stub.StreamObserver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import xyz.flipchat.services.domain.model.query.QueryOptions +import xyz.flipchat.services.internal.annotations.ChatManagedChannel +import xyz.flipchat.services.internal.network.extensions.toChatId +import xyz.flipchat.services.internal.network.extensions.toIntentId +import xyz.flipchat.services.internal.network.extensions.toMessageId +import xyz.flipchat.services.internal.network.extensions.toPaymentAmount +import xyz.flipchat.services.internal.network.extensions.toProto +import xyz.flipchat.services.internal.network.utils.authenticate +import javax.inject.Inject + +class MessagingApi @Inject constructor( + @ChatManagedChannel + managedChannel: ManagedChannel +) : GrpcApi(managedChannel) { + private val api = MessagingGrpc.newStub(managedChannel).withWaitForReady() + + /** + * gets the set of messages for a chat member using a paged API + */ + fun getMessages( + owner: KeyPair, + chatId: ID, + queryOptions: QueryOptions, + ): Flow { + val request = MessagingService.GetMessagesRequest.newBuilder() + .setChatId(chatId.toChatId()) + .setOptions(queryOptions.toProto()) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::getMessages + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + /** + * advances a pointer in message history for a chat member. + */ + fun advancePointer( + owner: KeyPair, + chatId: ID, + to: ID, + status: MessageStatus, + ): Flow { + val request = MessagingService.AdvancePointerRequest.newBuilder() + .setChatId(chatId.toChatId()) + .setPointer( + Pointer.newBuilder() + .setValue(Model.MessageId.newBuilder().setValue(to.toByteString())) + .setType( + when (status) { + MessageStatus.Sent -> Model.Pointer.Type.SENT + MessageStatus.Delivered -> Model.Pointer.Type.DELIVERED + MessageStatus.Read -> Model.Pointer.Type.READ + MessageStatus.Unknown -> Model.Pointer.Type.UNKNOWN + } + ) + ) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::advancePointer + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + /** + * sends a message to a chat. + */ + fun sendMessage( + owner: KeyPair, + chatId: ID, + content: OutgoingMessageContent, + observer: StreamObserver + ) { + val builder = MessagingService.SendMessageRequest.newBuilder() + .setChatId(chatId.toChatId()) + + val contentProto = when (content) { + is OutgoingMessageContent.Text -> Model.Content.newBuilder() + .setText(Model.TextContent.newBuilder().setText(content.text)) + + is OutgoingMessageContent.Reply -> { + Model.Content.newBuilder() + .setReply( + Model.ReplyContent.newBuilder() + .setOriginalMessageId(content.messageId.toMessageId()) + .setReplyText(content.text) + ) + } + + is OutgoingMessageContent.Reaction -> { + Model.Content.newBuilder() + .setReaction( + Model.ReactionContent.newBuilder() + .setOriginalMessageId(content.messageId.toMessageId()) + .setEmoji(content.emoji) + ) + } + is OutgoingMessageContent.Tip -> { + Model.Content.newBuilder() + .setTip( + Model.TipContent.newBuilder() + .setOriginalMessageId(content.messageId.toMessageId()) + .setTipAmount(content.amount.toPaymentAmount())) + + } + + is OutgoingMessageContent.DeleteRequest -> { + Model.Content.newBuilder() + .setDeleted( + Model.DeleteMessageContent.newBuilder() + .setOriginalMessageId(content.messageId.toMessageId()) + ) + } + } + + builder.addContent(contentProto) + + when (content) { + is OutgoingMessageContent.DeleteRequest -> Unit + is OutgoingMessageContent.Reaction -> Unit + is OutgoingMessageContent.Reply -> Unit + is OutgoingMessageContent.Text -> { + content.intentId?.let { id -> + builder.setPaymentIntent(id.toIntentId()) + } + } + is OutgoingMessageContent.Tip -> { + builder.setPaymentIntent(content.intentId.toIntentId()) + } + } + + val request = builder.apply { setAuth(authenticate(owner)) }.build() + + api.sendMessage(request, observer) + } + + fun notifyIsTyping( + owner: KeyPair, + chatId: ID, + isTyping: Boolean, + observer: StreamObserver + ) { + val request = MessagingService.NotifyIsTypingRequest.newBuilder() + .setChatId(chatId.toChatId()) + .setIsTyping(isTyping) + .apply { setAuth(authenticate(owner)) } + .build() + + api.notifyIsTyping(request, observer) + } + + fun streamMessages( + observer: StreamObserver + ): StreamObserver? { + return api.streamMessages(observer) + } + +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/api/ProfileApi.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/api/ProfileApi.kt new file mode 100644 index 000000000..116563433 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/api/ProfileApi.kt @@ -0,0 +1,47 @@ +package xyz.flipchat.services.internal.network.api + +import com.codeinc.flipchat.gen.profile.v1.ProfileGrpc +import com.codeinc.flipchat.gen.profile.v1.ProfileService +import com.codeinc.flipchat.gen.profile.v1.ProfileService.GetProfileRequest +import com.codeinc.flipchat.gen.profile.v1.ProfileService.SetDisplayNameRequest +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.model.ID +import com.getcode.services.network.core.GrpcApi +import io.grpc.ManagedChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import xyz.flipchat.services.internal.annotations.ChatManagedChannel +import xyz.flipchat.services.internal.network.extensions.toUserId +import xyz.flipchat.services.internal.network.utils.authenticate +import javax.inject.Inject + +class ProfileApi @Inject constructor( + @ChatManagedChannel + managedChannel: ManagedChannel, +) : GrpcApi(managedChannel) { + + private val api = ProfileGrpc.newStub(managedChannel).withWaitForReady() + + fun getProfile(userId: ID): Flow { + val request = GetProfileRequest.newBuilder() + .setUserId(userId.toUserId()) + .build() + + return api::getProfile + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + fun setDisplayName(owner: KeyPair, displayName: String): Flow { + val request = SetDisplayNameRequest.newBuilder() + .setDisplayName(displayName) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::setDisplayName + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/api/PurchaseApi.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/api/PurchaseApi.kt new file mode 100644 index 000000000..8acc84924 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/api/PurchaseApi.kt @@ -0,0 +1,37 @@ +package xyz.flipchat.services.internal.network.api + +import com.codeinc.flipchat.gen.common.v1.Common +import com.codeinc.flipchat.gen.iap.v1.IapGrpc +import com.codeinc.flipchat.gen.iap.v1.IapService +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.services.network.core.GrpcApi +import io.grpc.ManagedChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import xyz.flipchat.services.internal.annotations.ChatManagedChannel +import xyz.flipchat.services.internal.network.utils.authenticate +import javax.inject.Inject + +class PurchaseApi @Inject constructor( + @ChatManagedChannel + managedChannel: ManagedChannel, +) : GrpcApi(managedChannel) { + private val api = IapGrpc.newStub(managedChannel).withWaitForReady() + + // OnPurchaseCompleted is called when an IAP has been completed + fun onPurchaseCompleted( + owner: KeyPair, + receiptValue: String, + ): Flow { + val request = IapService.OnPurchaseCompletedRequest.newBuilder() + .setPlatform(Common.Platform.GOOGLE) + .setReceipt(IapService.Receipt.newBuilder().setValue(receiptValue)) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::onPurchaseCompleted + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/api/PushApi.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/api/PushApi.kt new file mode 100644 index 000000000..aa71002bc --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/api/PushApi.kt @@ -0,0 +1,72 @@ +package xyz.flipchat.services.internal.network.api + +import com.codeinc.flipchat.gen.common.v1.Common +import com.codeinc.flipchat.gen.push.v1.PushGrpc +import com.codeinc.flipchat.gen.push.v1.PushService +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.services.network.core.GrpcApi +import io.grpc.ManagedChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import xyz.flipchat.services.internal.annotations.ChatManagedChannel +import xyz.flipchat.services.internal.network.utils.authenticate +import javax.inject.Inject + +class PushApi @Inject constructor( + @ChatManagedChannel + managedChannel: ManagedChannel, +) : GrpcApi(managedChannel) { + + private val api = PushGrpc.newStub(managedChannel).withWaitForReady() + + fun addToken( + owner: KeyPair, + token: String, + installationId: String? + ): Flow { + val request = + PushService.AddTokenRequest.newBuilder() + .setPushToken(token) + .setAppInstall(Common.AppInstallId.newBuilder().setValue(installationId)) + .setTokenType(PushService.TokenType.FCM_ANDROID) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::addToken + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + fun deleteToken( + owner: KeyPair, + token: String, + ): Flow { + val request = + PushService.DeleteTokenRequest.newBuilder() + .setPushToken(token) + .setTokenType(PushService.TokenType.FCM_ANDROID) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::deleteToken + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + fun deleteTokens( + owner: KeyPair, + installationId: String?, + ): Flow { + val request = + PushService.DeleteTokensRequest.newBuilder() + .setAppInstall(Common.AppInstallId.newBuilder().setValue(installationId)) + .apply { setAuth(authenticate(owner)) } + .build() + + return api::deleteTokens + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } +} + diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/chat/ChatStreamUpdate.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/chat/ChatStreamUpdate.kt new file mode 100644 index 000000000..2328375d8 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/chat/ChatStreamUpdate.kt @@ -0,0 +1,68 @@ +package xyz.flipchat.services.internal.network.chat + +import com.codeinc.flipchat.gen.chat.v1.ChatService.MemberUpdate +import com.codeinc.flipchat.gen.chat.v1.ChatService.MetadataUpdate +import com.codeinc.flipchat.gen.chat.v1.isTypingOrNull +import com.codeinc.flipchat.gen.chat.v1.lastMessageOrNull +import com.codeinc.flipchat.gen.chat.v1.memberOrNull +import com.codeinc.flipchat.gen.chat.v1.pointerOrNull +import com.codeinc.flipchat.gen.messaging.v1.Model +import com.codeinc.flipchat.gen.messaging.v1.Model.Pointer +import com.getcode.model.ID +import com.getcode.model.chat.MessageContent +import com.getcode.utils.base58 +import xyz.flipchat.services.internal.protomapping.invoke +import com.codeinc.flipchat.gen.chat.v1.ChatService as ChatServiceRpc + +data class ChatStreamUpdate( + val id: ID, + val metadataUpdates: List, + val memberUpdates: List, + val lastMessage: Model.Message?, + val lastPointer: PointerUpdate?, + val isTyping: Boolean?, +) { + companion object { + operator fun invoke(proto: ChatServiceRpc.StreamChatEventsResponse.ChatUpdate?): ChatStreamUpdate? { + proto ?: return null + val chatId = proto.chatId.value.toByteArray().toList() + val lastMessage = proto.lastMessageOrNull + val lastPointer = proto.pointerOrNull?.let { + PointerUpdate( + it.memberOrNull?.value?.toByteArray()?.toList(), + it.pointerOrNull + ) + } + val isTyping = proto.isTypingOrNull + + return ChatStreamUpdate( + id = chatId, + metadataUpdates = proto.metadataUpdatesList, + lastMessage = lastMessage, + memberUpdates = proto.memberUpdatesList, + lastPointer = lastPointer, + isTyping = isTyping?.isTyping, + ) + } + } + + override fun toString(): String { + return "ID: ${id.base58}, " + + "metadata updates=${metadataUpdates.count()}, " + + "member updates=${memberUpdates.count()}, " + + "message update=${lastMessage?.contentList?.mapNotNull { + MessageContent.invoke( + it, + emptyList(), + false + ) + }?.joinToString()}, " + + "pointer update=${lastPointer != null}" + + } +} + +data class PointerUpdate( + val userId: ID?, + val pointer: Pointer?, +) diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/chat/GetOrJoinChatResponse.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/chat/GetOrJoinChatResponse.kt new file mode 100644 index 000000000..b27009a04 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/chat/GetOrJoinChatResponse.kt @@ -0,0 +1,8 @@ +package xyz.flipchat.services.internal.network.chat + +import com.codeinc.flipchat.gen.chat.v1.ChatService as ChatServiceRpc + +data class GetOrJoinChatResponse( + val metadata: ChatServiceRpc.Metadata, + val members: List +) \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/extensions/Extensions.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/extensions/Extensions.kt new file mode 100644 index 000000000..afc6f85d0 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/extensions/Extensions.kt @@ -0,0 +1,61 @@ +package xyz.flipchat.services.internal.network.extensions + +import com.codeinc.flipchat.gen.common.v1.Common +import com.codeinc.flipchat.gen.messaging.v1.Model +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.model.ID +import com.getcode.model.KinAmount +import com.getcode.solana.keys.PublicKey +import com.getcode.utils.toByteString +import xyz.flipchat.services.domain.model.query.PagingToken +import xyz.flipchat.services.domain.model.query.QueryOptions + +internal fun ByteArray.toSignature(): Common.Signature { + return Common.Signature.newBuilder().setValue(this.toByteString()) + .build() +} + +internal fun KeyPair.asPublicKey(): Common.PublicKey { + return Common.PublicKey.newBuilder().setValue(this.publicKeyBytes.toByteString()).build() +} + +internal fun ID.toUserId(): Common.UserId { + return Common.UserId.newBuilder().setValue(toByteString()).build() +} + +internal fun ID.toMessageId(): Model.MessageId { + return Model.MessageId.newBuilder().setValue(toByteString()).build() +} + +internal fun ID.toChatId(): Common.ChatId { + return Common.ChatId.newBuilder().setValue(toByteString()).build() +} + +internal fun ID.toIntentId(): Common.IntentId { + return Common.IntentId.newBuilder().setValue(toByteString()).build() +} + +internal fun Common.PublicKey.toPublicKey(): PublicKey { + return PublicKey(this.value.toByteArray().toList()) +} + +internal fun KinAmount.toPaymentAmount(): Common.PaymentAmount { + return Common.PaymentAmount.newBuilder().setQuarks(this.kin.quarks).build() +} + +internal fun QueryOptions.toProto(): Common.QueryOptions { + return Common.QueryOptions.newBuilder() + .setPageSize(this@toProto.limit.toLong()) + .setOrder( + if (this@toProto.descending) Common.QueryOptions.Order.DESC + else Common.QueryOptions.Order.ASC + ).apply { + this@toProto.token?.let { + setPagingToken(it.toPagingToken()) + } + }.build() +} + +internal fun PagingToken.toPagingToken(): Common.PagingToken { + return Common.PagingToken.newBuilder().setValue(this.toByteString()).build() +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/accounts/AccountRepository.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/accounts/AccountRepository.kt new file mode 100644 index 000000000..11c8b0d0d --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/accounts/AccountRepository.kt @@ -0,0 +1,14 @@ +package xyz.flipchat.services.internal.network.repository.accounts + +import com.getcode.model.ID +import com.getcode.solana.keys.PublicKey +import xyz.flipchat.services.data.PaymentTarget +import xyz.flipchat.services.user.UserFlags + +interface AccountRepository { + suspend fun createAccount(): Result + suspend fun register(displayName: String): Result + suspend fun login(): Result + suspend fun getPaymentDestination(target: PaymentTarget): Result + suspend fun getUserFlags(): Result +} diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/accounts/RealAccountRepository.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/accounts/RealAccountRepository.kt new file mode 100644 index 000000000..ffb24eca1 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/accounts/RealAccountRepository.kt @@ -0,0 +1,58 @@ +package xyz.flipchat.services.internal.network.repository.accounts + +import com.getcode.model.ID +import com.getcode.solana.keys.PublicKey +import com.getcode.utils.ErrorUtils +import xyz.flipchat.services.data.PaymentTarget +import xyz.flipchat.services.user.UserFlags +import xyz.flipchat.services.internal.data.mapper.UserFlagsMapper +import xyz.flipchat.services.internal.network.service.AccountService +import xyz.flipchat.services.internal.network.service.RegisterError +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class RealAccountRepository @Inject constructor( + private val userManager: UserManager, + private val service: AccountService, + private val userFlagsMapper: UserFlagsMapper, +) : AccountRepository { + + override suspend fun createAccount(): Result { + val owner = userManager.keyPair ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + return service.register( + owner = owner, + displayName = null + ).onFailure { ErrorUtils.handleError(it) } + } + + @Deprecated("Being replaced with a delayed account creation flow") + @Throws(RegisterError::class, IllegalStateException::class) + override suspend fun register(displayName: String): Result { + val owner = userManager.keyPair ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + return service.register( + owner = owner, + displayName = displayName + ).onFailure { ErrorUtils.handleError(it) } + } + + override suspend fun login(): Result { + val owner = userManager.keyPair ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + return service.login(owner) + .onFailure { ErrorUtils.handleError(it) } + } + + override suspend fun getPaymentDestination(target: PaymentTarget): Result { + return service.getPaymentDestination(target) + .onFailure { ErrorUtils.handleError(it) } + } + + override suspend fun getUserFlags(): Result { + val owner = userManager.keyPair ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + val userId = userManager.userId ?: return Result.failure(IllegalStateException("No userId found")) + return service.getUserFlags(owner, userId) + .map { userFlagsMapper.map(it) } + .onFailure { ErrorUtils.handleError(it) } + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/chat/ChatRepository.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/chat/ChatRepository.kt new file mode 100644 index 000000000..926e39520 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/chat/ChatRepository.kt @@ -0,0 +1,55 @@ +package xyz.flipchat.services.internal.network.repository.chat + +import com.getcode.model.ID +import com.getcode.model.KinAmount +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import xyz.flipchat.services.data.ChatIdentifier +import xyz.flipchat.services.data.Member +import xyz.flipchat.services.data.Room +import xyz.flipchat.services.data.RoomWithMembers +import xyz.flipchat.services.data.StartChatRequestType +import xyz.flipchat.services.domain.model.chat.StreamMemberUpdate +import xyz.flipchat.services.domain.model.chat.db.ChatUpdate +import xyz.flipchat.services.domain.model.query.QueryOptions + +interface ChatRepository { + suspend fun getChats( + queryOptions: QueryOptions = QueryOptions() + ): Result> + + suspend fun getChat(identifier: ChatIdentifier): Result + suspend fun getChatMembers(identifier: ChatIdentifier): Result> + suspend fun startChat(type: StartChatRequestType): Result + suspend fun joinChat( + identifier: ChatIdentifier, + paymentId: ID? = null, + ): Result + suspend fun getMemberUpdates(chatId: ID, afterMember: ID? = null): Result> + fun observeTyping(chatId: ID): Flow + fun openEventStream(coroutineScope: CoroutineScope, onEvent: (ChatUpdate) -> Unit) + fun closeEventStream() + + val typingChats: StateFlow> + + // User actions + suspend fun leaveChat(chatId: ID): Result + + // Host controls + suspend fun setDisplayName(chatId: ID, displayName: String): Result + suspend fun mute(chatId: ID): Result + suspend fun unmute(chatId: ID): Result + @Deprecated("Replaced by setMessagingFee") + suspend fun setCoverCharge(chatId: ID, amount: KinAmount): Result + suspend fun setMessagingFee(chatId: ID, amount: KinAmount): Result + suspend fun promoteUser(chatId: ID, userId: ID): Result + suspend fun demoteUser(chatId: ID, userId: ID): Result + suspend fun enableChat(chatId: ID): Result + suspend fun disableChat(chatId: ID): Result + + // Self Defense Room Controls + suspend fun removeUser(chatId: ID, userId: ID): Result + suspend fun reportUserForMessage(userId: ID, messageId: ID): Result + suspend fun muteUser(chatId: ID, userId: ID): Result +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/chat/RealChatRepository.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/chat/RealChatRepository.kt new file mode 100644 index 000000000..de84540fc --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/chat/RealChatRepository.kt @@ -0,0 +1,391 @@ +package xyz.flipchat.services.internal.network.repository.chat + +import com.getcode.model.ID +import com.getcode.model.KinAmount +import com.getcode.utils.ErrorUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import xyz.flipchat.services.data.ChatIdentifier +import xyz.flipchat.services.data.Member +import xyz.flipchat.services.data.Room +import xyz.flipchat.services.data.RoomWithMembers +import xyz.flipchat.services.data.StartChatRequestType +import xyz.flipchat.services.domain.mapper.ConversationMessageMapper +import xyz.flipchat.services.domain.mapper.RoomConversationMapper +import xyz.flipchat.services.domain.model.chat.StreamMemberUpdate +import xyz.flipchat.services.domain.model.chat.db.ChatUpdate +import xyz.flipchat.services.domain.model.chat.db.ConversationMemberUpdate +import xyz.flipchat.services.domain.model.query.QueryOptions +import xyz.flipchat.services.internal.data.mapper.ConversationMemberMapper +import xyz.flipchat.services.internal.data.mapper.LastMessageMapper +import xyz.flipchat.services.internal.data.mapper.MemberUpdateMapper +import xyz.flipchat.services.internal.data.mapper.MetadataRoomMapper +import xyz.flipchat.services.internal.data.mapper.MetadataUpdateMapper +import xyz.flipchat.services.internal.data.mapper.RoomWithMembersMapper +import xyz.flipchat.services.internal.data.mapper.StreamMetadataUpdateMapper +import xyz.flipchat.services.internal.network.chat.ChatStreamUpdate +import xyz.flipchat.services.internal.network.service.ChatHomeStreamReference +import xyz.flipchat.services.internal.network.service.ChatService +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class RealChatRepository @Inject constructor( + private val userManager: UserManager, + private val service: ChatService, + private val roomMapper: MetadataRoomMapper, + private val roomWithMembersMapper: RoomWithMembersMapper, + private val conversationMapper: RoomConversationMapper, + private val metadataUpdateMapper: MetadataUpdateMapper, + private val streamMetadataUpdateMapper: StreamMetadataUpdateMapper, + private val memberUpdateMapper: MemberUpdateMapper, + private val conversationMemberMapper: ConversationMemberMapper, + private val lastMessageMapper: LastMessageMapper, + private val messageMapper: ConversationMessageMapper, +) : ChatRepository { + private var homeStreamReference: ChatHomeStreamReference? = null + private val _typingChats = MutableStateFlow>(emptyList()) + override val typingChats: StateFlow> + get() = _typingChats.asStateFlow() + + override suspend fun getChats( + queryOptions: QueryOptions, + ): Result> { + val owner = userManager.keyPair + ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + + return withContext(Dispatchers.IO) { + service.getChats(owner, queryOptions) + .map { it.map { meta -> roomMapper.map(meta) } } + .onFailure { ErrorUtils.handleError(it) } + } + } + + override suspend fun getChat(identifier: ChatIdentifier): Result { + val owner = userManager.keyPair + ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + + return withContext(Dispatchers.IO) { + service.getChat(owner, identifier) + .map { roomWithMembersMapper.map(it) } + .onFailure { ErrorUtils.handleError(it) } + } + } + + override suspend fun getChatMembers(identifier: ChatIdentifier): Result> { + val owner = userManager.keyPair + ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + + return withContext(Dispatchers.IO) { + service.getChat(owner, identifier) + .map { roomWithMembersMapper.map(it) } + .map { it.members } + .onFailure { ErrorUtils.handleError(it) } + } + } + + override suspend fun startChat(type: StartChatRequestType): Result { + val owner = userManager.keyPair + ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + + return withContext(Dispatchers.IO) { + service.startChat(owner, type) + .map { roomWithMembersMapper.map(it) } + .onFailure { ErrorUtils.handleError(it) } + } + } + + override suspend fun joinChat( + identifier: ChatIdentifier, + paymentId: ID? + ): Result { + val owner = userManager.keyPair + ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + + return withContext(Dispatchers.IO) { + service.joinChat(owner, identifier, paymentId) + .map { roomWithMembersMapper.map(it) } + .onFailure { ErrorUtils.handleError(it) } + } + } + + override suspend fun leaveChat(chatId: ID): Result { + val owner = userManager.keyPair + ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + + return withContext(Dispatchers.IO) { + service.leaveChat(owner, chatId) + .onFailure { ErrorUtils.handleError(it) } + } + } + + override suspend fun setDisplayName(chatId: ID, displayName: String): Result { + val owner = userManager.keyPair + ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + + return withContext(Dispatchers.IO) { + service.setDisplayName(owner, chatId, displayName) + .onFailure { ErrorUtils.handleError(it) } + } + } + + override suspend fun mute(chatId: ID): Result { + val owner = userManager.keyPair + ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + + return withContext(Dispatchers.IO) { + service.muteChat(owner, chatId) + .onFailure { ErrorUtils.handleError(it) } + } + } + + override suspend fun unmute(chatId: ID): Result { + val owner = userManager.keyPair + ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + + return withContext(Dispatchers.IO) { + service.unmuteChat(owner, chatId) + .onFailure { ErrorUtils.handleError(it) } + } + } + + @Deprecated("Replaced by setMessagingFee") + override suspend fun setCoverCharge(chatId: ID, amount: KinAmount): Result { + val owner = userManager.keyPair + ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + + return withContext(Dispatchers.IO) { + service.setCoverCharge(owner, chatId, amount) + .onFailure { ErrorUtils.handleError(it) } + } + } + + override suspend fun setMessagingFee(chatId: ID, amount: KinAmount): Result { + val owner = userManager.keyPair + ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + + return withContext(Dispatchers.IO) { + service.setMessagingFee(owner, chatId, amount) + .onFailure { ErrorUtils.handleError(it) } + } + } + + override suspend fun enableChat(chatId: ID): Result { + val owner = userManager.keyPair + ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + + return withContext(Dispatchers.IO) { + service.enableChat(owner, chatId) + .onFailure { ErrorUtils.handleError(it) } + } + } + + override suspend fun disableChat(chatId: ID): Result { + val owner = userManager.keyPair + ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + + return withContext(Dispatchers.IO) { + service.disableChat(owner, chatId) + .onFailure { ErrorUtils.handleError(it) } + } + } + + override suspend fun getMemberUpdates(chatId: ID, afterMember: ID?): Result> { + val owner = userManager.keyPair + ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + + return withContext(Dispatchers.IO) { + service.getMemberUpdates(owner, chatId, afterMember) + .map { updates -> updates.mapNotNull { memberUpdateMapper.map(it) } } + .onFailure { ErrorUtils.handleError(it) } + } + } + + override suspend fun promoteUser(chatId: ID, userId: ID): Result { + val owner = userManager.keyPair + ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + + return withContext(Dispatchers.IO) { + service.promoteUser(owner, chatId, userId) + .onFailure { ErrorUtils.handleError(it) } + } + } + + override suspend fun demoteUser(chatId: ID, userId: ID): Result { + val owner = userManager.keyPair + ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + + return withContext(Dispatchers.IO) { + service.demoteUser(owner, chatId, userId) + .onFailure { ErrorUtils.handleError(it) } + } + } + + override fun observeTyping(chatId: ID): Flow { + return typingChats + .map { chatId in it } + } + + override fun openEventStream(coroutineScope: CoroutineScope, onEvent: (ChatUpdate) -> Unit) { + val owner = userManager.keyPair ?: throw IllegalStateException("No keypair found for owner") + val userId = userManager.userId ?: throw IllegalStateException("user not established") + if (homeStreamReference == null) { + homeStreamReference = service.openChatStream(coroutineScope, owner) { result -> + if (result.isSuccess) { + val data = result.getOrNull() ?: return@openChatStream + val updates = data.mapNotNull { ChatStreamUpdate.invoke(it) } + + updates.onEach { update -> + // handle typing state changes + if (update.isTyping != null) { + if (update.isTyping) { + _typingChats.update { it + listOf(update.id).toSet() } + } else { + _typingChats.update { it - listOf(update.id).toSet() } + } + } + + val memberUpdates = update.memberUpdates.map { memberUpdateMapper.map(it) } + + val streamMetadataUpdates = update.metadataUpdates.mapNotNull { metadataUpdateMapper.map(it) } + val metadataUpdates = streamMetadataUpdates.map { streamMetadataUpdateMapper.map(update.id to it) } + + // handle last message update + val message = if (userManager.openRoom != update.id) { + update.lastMessage?.let { + val chatId = update.id + val mapped = lastMessageMapper.map(userId to it) + messageMapper.map(chatId to mapped) + } + } else { + null + } + + val convoMemberUpdates = memberUpdates.mapNotNull { memberUpdate -> + when (memberUpdate) { + is StreamMemberUpdate.Refresh -> { + val members = memberUpdate.members.map { + conversationMemberMapper.map( + Pair( + update.id, + it + ) + ) + } + ConversationMemberUpdate.FullRefresh(members) + } + + is StreamMemberUpdate.IndividualRefresh -> { + ConversationMemberUpdate.IndividualRefresh( + conversationMemberMapper.map( + Pair(update.id, memberUpdate.member) + ) + ) + } + + is StreamMemberUpdate.Joined -> { + ConversationMemberUpdate.Joined( + conversationMemberMapper.map( + Pair(update.id, memberUpdate.member) + ) + ) + } + + is StreamMemberUpdate.Left -> { + ConversationMemberUpdate.Left(update.id, memberUpdate.memberId) + } + + is StreamMemberUpdate.Muted -> { + ConversationMemberUpdate.Muted( + update.id, + memberUpdate.memberId, + memberUpdate.mutedBy + ) + } + + is StreamMemberUpdate.Removed -> { + ConversationMemberUpdate.Removed( + update.id, + memberUpdate.memberId, + memberUpdate.removedBy + ) + } + + is StreamMemberUpdate.Demoted -> { + ConversationMemberUpdate.Demoted( + update.id, + memberUpdate.memberId, + memberUpdate.by + ) + } + is StreamMemberUpdate.Promoted -> { + ConversationMemberUpdate.Promoted( + update.id, + memberUpdate.memberId, + memberUpdate.by + ) + } + null -> null + } + } + + onEvent( + ChatUpdate( + metadata = metadataUpdates, + members = convoMemberUpdates, + message = message + ) + ) + } + } else { + result.exceptionOrNull()?.let { + ErrorUtils.handleError(it) + } + } + } + } + } + + override fun closeEventStream() { + homeStreamReference?.destroy() + homeStreamReference = null + } + + override suspend fun removeUser(chatId: ID, userId: ID): Result { + val owner = userManager.keyPair + ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + return withContext(Dispatchers.IO) { + service.removeUser(owner, chatId, userId) + .onFailure { ErrorUtils.handleError(it) } + } + } + + override suspend fun reportUserForMessage( + userId: ID, + messageId: ID + ): Result { + val owner = userManager.keyPair + ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + return withContext(Dispatchers.IO) { + service.reportUser(owner, userId, messageId) + .onFailure { ErrorUtils.handleError(it) } + } + } + + override suspend fun muteUser(chatId: ID, userId: ID): Result { + val owner = userManager.keyPair + ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + return withContext(Dispatchers.IO) { + service.muteUser(owner, chatId, userId) + .onFailure { ErrorUtils.handleError(it) } + } + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/iap/InAppPurchaseRepository.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/iap/InAppPurchaseRepository.kt new file mode 100644 index 000000000..c33c2ffaa --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/iap/InAppPurchaseRepository.kt @@ -0,0 +1,5 @@ +package xyz.flipchat.services.internal.network.repository.iap + +interface InAppPurchaseRepository { + suspend fun onPurchaseCompleted(receipt: String): Result +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/iap/RealInAppPurchaseRepository.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/iap/RealInAppPurchaseRepository.kt new file mode 100644 index 000000000..7a2c0e7f0 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/iap/RealInAppPurchaseRepository.kt @@ -0,0 +1,19 @@ +package xyz.flipchat.services.internal.network.repository.iap + +import com.getcode.utils.ErrorUtils +import xyz.flipchat.services.internal.network.service.PurchaseService +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class RealInAppPurchaseRepository @Inject constructor( + private val userManager: UserManager, + private val service: PurchaseService, +) : InAppPurchaseRepository { + override suspend fun onPurchaseCompleted(receipt: String): Result { + val owner = userManager.keyPair ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + return service.onPurchaseCompleted(owner, receipt) + .onFailure { ErrorUtils.handleError(it) } + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/messaging/MessagingRepository.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/messaging/MessagingRepository.kt new file mode 100644 index 000000000..93b78a91e --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/messaging/MessagingRepository.kt @@ -0,0 +1,31 @@ +package xyz.flipchat.services.internal.network.repository.messaging + +import com.getcode.model.ID +import com.getcode.model.chat.ChatMessage +import com.getcode.model.chat.MessageStatus +import com.getcode.services.model.chat.OutgoingMessageContent +import kotlinx.coroutines.CoroutineScope +import xyz.flipchat.services.domain.model.chat.ConversationMessage +import xyz.flipchat.services.domain.model.query.QueryOptions + +interface MessagingRepository { + suspend fun getMessages( + chatId: ID, + queryOptions: QueryOptions = QueryOptions(), + ): Result> + + suspend fun sendMessage(chatId: ID, content: OutgoingMessageContent): Result + suspend fun advancePointer(chatId: ID, messageId: ID, status: MessageStatus): Result + suspend fun onStartedTyping(chatId: ID): Result + suspend fun onStoppedTyping(chatId: ID): Result + fun openMessageStream( + coroutineScope: CoroutineScope, + chatId: ID, + onMessagesUpdated: (List) -> Unit, + ) + + fun closeMessageStream() + + // Self Defense Room Controls + suspend fun deleteMessage(chatId: ID, messageId: ID): Result +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/messaging/RealMessagingRepository.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/messaging/RealMessagingRepository.kt new file mode 100644 index 000000000..ce9344265 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/messaging/RealMessagingRepository.kt @@ -0,0 +1,128 @@ +package xyz.flipchat.services.internal.network.repository.messaging + +import com.getcode.model.ID +import com.getcode.model.chat.ChatMessage +import com.getcode.model.chat.MessageContent +import com.getcode.model.chat.MessageStatus +import com.getcode.services.model.chat.OutgoingMessageContent +import com.getcode.utils.ErrorUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import xyz.flipchat.services.domain.mapper.ConversationMessageMapper +import xyz.flipchat.services.domain.model.chat.ConversationMessage +import xyz.flipchat.services.domain.model.query.QueryOptions +import xyz.flipchat.services.internal.data.mapper.ChatMessageMapper +import xyz.flipchat.services.internal.data.mapper.LastMessageMapper +import xyz.flipchat.services.internal.network.service.ChatMessageStreamReference +import xyz.flipchat.services.internal.network.service.MessagingService +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject + +internal class RealMessagingRepository @Inject constructor( + private val userManager: UserManager, + private val service: MessagingService, + private val chatMessageMapper: ChatMessageMapper, + private val lastMessageMapper: LastMessageMapper, + private val messageMapper: ConversationMessageMapper, +): MessagingRepository { + private var messageStream: ChatMessageStreamReference? = null + + override suspend fun getMessages( + chatId: ID, + queryOptions: QueryOptions, + ): Result> { + val owner = userManager.keyPair ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + val userId = userManager.userId ?: return Result.failure(IllegalStateException("No userId found for owner")) + + return withContext(Dispatchers.IO) { + service.getMessages(owner, chatId, queryOptions) + .map { it.map { meta -> chatMessageMapper.map(userId to meta) } } + .onFailure { ErrorUtils.handleError(it) } + } + } + + override suspend fun sendMessage(chatId: ID, content: OutgoingMessageContent): Result { + val owner = userManager.keyPair ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + val userId = userManager.userId ?: return Result.failure(IllegalStateException("No userId found for owner")) + + return withContext(Dispatchers.IO) { + service.sendMessage(owner, chatId, content) + .map { lastMessageMapper.map(userId to it) } + .onFailure { ErrorUtils.handleError(it) } + } + } + + override suspend fun deleteMessage(chatId: ID, messageId: ID): Result { + // this utilizes send message under the hood + val content = OutgoingMessageContent.DeleteRequest(messageId) + return sendMessage(chatId, content) + .map { Unit } + } + + override suspend fun advancePointer(chatId: ID, messageId: ID, status: MessageStatus): Result { + val owner = userManager.keyPair ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + + return withContext(Dispatchers.IO) { + service.advancePointer(owner, chatId, messageId, status) + .onFailure { ErrorUtils.handleError(it) } + } + } + + override suspend fun onStartedTyping(chatId: ID): Result { + val owner = userManager.keyPair ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + + return withContext(Dispatchers.IO) { + service.notifyIsTyping(owner, chatId, true) + .onFailure { ErrorUtils.handleError(it) } + } + } + + override suspend fun onStoppedTyping(chatId: ID): Result { + val owner = userManager.keyPair ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + + return withContext(Dispatchers.IO) { + service.notifyIsTyping(owner, chatId, false) + .onFailure { ErrorUtils.handleError(it) } + } + } + + override fun openMessageStream( + coroutineScope: CoroutineScope, + chatId: ID, + onMessagesUpdated: (List) -> Unit, + ) { + val owner = userManager.keyPair ?: throw IllegalStateException("No ed25519 signature found for owner") + val userId = userManager.userId ?: throw IllegalStateException("No userId found for owner") + + if (messageStream == null) { + messageStream = service.openMessageStream( + scope = coroutineScope, + owner = owner, + chatId = chatId, + ) stream@{ result -> + if (result.isSuccess) { + val data = result.getOrNull() ?: return@stream + val messages = data.map { lastMessageMapper.map(userId to it) } + val messagesWithContents = messages.map { messageMapper.map(chatId to it) } + + onMessagesUpdated(messagesWithContents) + } else { + result.exceptionOrNull()?.let { + ErrorUtils.handleError(it) + } + } + } + + messageStream?.onConnect = { + userManager.roomOpened(roomId = chatId) + } + } + } + + override fun closeMessageStream() { + userManager.roomClosed() + messageStream?.destroy() + messageStream = null + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/profile/ProfileRepository.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/profile/ProfileRepository.kt new file mode 100644 index 000000000..168ec5c4b --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/profile/ProfileRepository.kt @@ -0,0 +1,9 @@ +package xyz.flipchat.services.internal.network.repository.profile + +import com.getcode.model.ID +import xyz.flipchat.services.domain.model.profile.UserProfile + +interface ProfileRepository { + suspend fun getProfile(userId: ID): Result + suspend fun setDisplayName(name: String): Result +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/profile/RealProfileRepository.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/profile/RealProfileRepository.kt new file mode 100644 index 000000000..b8a400bf5 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/profile/RealProfileRepository.kt @@ -0,0 +1,28 @@ +package xyz.flipchat.services.internal.network.repository.profile + +import com.getcode.model.ID +import com.getcode.utils.ErrorUtils +import xyz.flipchat.services.domain.model.profile.UserProfile +import xyz.flipchat.services.internal.data.mapper.ProfileMapper +import xyz.flipchat.services.internal.network.service.ProfileService +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject + +internal class RealProfileRepository @Inject constructor( + private val userManager: UserManager, + private val service: ProfileService, + private val profileMapper: ProfileMapper, +) : ProfileRepository { + override suspend fun getProfile(userId: ID): Result { + return service.getProfile(userId) + .map { profileMapper.map(it) } + .onFailure { ErrorUtils.handleError(it) } + } + + override suspend fun setDisplayName(name: String): Result { + val owner = userManager.keyPair ?: return Result.failure(IllegalStateException("No ed25519 signature found for owner")) + + return service.setDisplayName(owner, name) + .onFailure { ErrorUtils.handleError(it) } + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/push/PushRepository.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/push/PushRepository.kt new file mode 100644 index 000000000..56e1b7523 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/push/PushRepository.kt @@ -0,0 +1,23 @@ +package xyz.flipchat.services.internal.network.repository.push + +import com.getcode.ed25519.Ed25519 +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.model.ID + +interface PushRepository { + suspend fun addToken( + owner: KeyPair, + token: String, + installationId: String? + ): Result + + suspend fun deleteToken( + owner: KeyPair, + token: String + ): Result + + suspend fun deleteTokens( + owner: KeyPair, + installationId: String? + ): Result +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/push/RealPushRepository.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/push/RealPushRepository.kt new file mode 100644 index 000000000..79c956deb --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/repository/push/RealPushRepository.kt @@ -0,0 +1,28 @@ +package xyz.flipchat.services.internal.network.repository.push + +import com.getcode.ed25519.Ed25519 +import com.getcode.model.ID +import com.getcode.utils.ErrorUtils +import xyz.flipchat.services.internal.network.service.PushService +import javax.inject.Inject + +internal class RealPushRepository @Inject constructor( + private val service: PushService +) : PushRepository { + override suspend fun addToken( + owner: Ed25519.KeyPair, + token: String, + installationId: String? + ): Result { + return service.addToken(owner, token, installationId) + .onFailure { ErrorUtils.handleError(it) } + } + + override suspend fun deleteToken(owner: Ed25519.KeyPair, token: String): Result { + return service.deleteToken(owner, token) + } + + override suspend fun deleteTokens(owner: Ed25519.KeyPair, installationId: String?): Result { + return service.deleteTokens(owner, installationId) + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/AccountService.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/AccountService.kt new file mode 100644 index 000000000..94f00d16c --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/AccountService.kt @@ -0,0 +1,221 @@ +package xyz.flipchat.services.internal.network.service + +import com.codeinc.flipchat.gen.account.v1.AccountService +import com.codeinc.flipchat.gen.account.v1.AccountService.LoginResponse +import com.codeinc.flipchat.gen.account.v1.AccountService.RegisterResponse +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.model.ID +import com.getcode.services.network.core.NetworkOracle +import com.getcode.solana.keys.PublicKey +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import timber.log.Timber +import xyz.flipchat.services.data.PaymentTarget +import xyz.flipchat.services.internal.network.api.AccountApi +import com.getcode.utils.FlipchatServerError +import xyz.flipchat.services.internal.network.extensions.toPublicKey +import javax.inject.Inject + +internal class AccountService @Inject constructor( + private val api: AccountApi, + private val networkOracle: NetworkOracle, +) { + suspend fun register(owner: KeyPair, displayName: String?): Result { + return try { + networkOracle.managedRequest(api.register(owner, displayName)) + .map { response -> + when (response.result) { + RegisterResponse.Result.OK -> { + Result.success(response.userId.value.toByteArray().toList()) + } + + RegisterResponse.Result.INVALID_SIGNATURE -> { + val error = RegisterError.InvalidSignature(response.errorReason) + Timber.e(t = error) + Result.failure(error) + } + + RegisterResponse.Result.INVALID_DISPLAY_NAME -> { + val error = RegisterError.InvalidDisplayName(response.errorReason) + Timber.e(t = error) + Result.failure(error) + } + + RegisterResponse.Result.DENIED -> { + val error = RegisterError.Denied(response.errorReason) + Timber.e(t = error) + Result.failure(error) + } + + RegisterResponse.Result.UNRECOGNIZED -> { + val error = RegisterError.Unrecognized(response.errorReason) + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = RegisterError.Other("Failed to register") + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = RegisterError.Other( + "Road to greatness is bumpy. Apologies for the hiccup.", + cause = e + ) + Result.failure(error) + } + } + + suspend fun login(owner: KeyPair): Result { + return try { + networkOracle.managedRequest(api.login(owner)) + .map { response -> + when (response.result) { + LoginResponse.Result.OK -> { + Result.success(response.userId.value.toByteArray().toList()) + } + + LoginResponse.Result.UNRECOGNIZED -> { + val error = LoginError.Unrecognized("Failed to login") + Timber.e(t = error) + Result.failure(error) + } + + LoginResponse.Result.INVALID_TIMESTAMP -> { + val error = LoginError.InvalidTimestamp("Failed to login") + Timber.e(t = error) + Result.failure(error) + } + + LoginResponse.Result.DENIED -> { + val error = LoginError.Denied("Failed to login") + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = LoginError.Other("Failed to login") + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = RegisterError.Other( + "Road to greatness is bumpy. Apologies for the hiccup.", + cause = e + ) + Result.failure(error) + } + } + + suspend fun getPaymentDestination(target: PaymentTarget): Result { + return try { + networkOracle.managedRequest(api.getPaymentDestination(target)) + .map { response -> + when (response.result) { + AccountService.GetPaymentDestinationResponse.Result.OK -> Result.success( + response.paymentDestination.toPublicKey() + ) + + AccountService.GetPaymentDestinationResponse.Result.NOT_FOUND -> { + val error = GetPaymentDestinationError.NotFound() + Timber.e(t = error) + Result.failure(error) + } + + AccountService.GetPaymentDestinationResponse.Result.UNRECOGNIZED -> { + val error = GetPaymentDestinationError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = GetPaymentDestinationError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = GetPaymentDestinationError.Other(cause = e) + Result.failure(error) + } + } + + suspend fun getUserFlags(owner: KeyPair, userId: ID): Result { + return try { + networkOracle.managedRequest(api.getUserFlags(owner = owner, userId = userId)) + .map { response -> + when (response.result) { + AccountService.GetUserFlagsResponse.Result.OK -> Result.success(response.userFlags) + AccountService.GetUserFlagsResponse.Result.DENIED -> { + val error = GetUserFlagsError.Denied() + Timber.e(t = error) + Result.failure(error) + } + + AccountService.GetUserFlagsResponse.Result.UNRECOGNIZED -> { + val error = GetUserFlagsError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = GetUserFlagsError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = GetUserFlagsError.Other(cause = e) + Result.failure(error) + } + } +} + +sealed class LoginError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + data class InvalidTimestamp(override val message: String) : LoginError(message) + data class NotFound(override val message: String) : LoginError(message) + data class Denied(override val message: String) : LoginError(message) + data class Unrecognized(override val message: String) : LoginError(message) + data class Other(override val message: String, override val cause: Throwable? = null) : + LoginError(message, cause) +} + +sealed class RegisterError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + data class InvalidSignature(override val message: String) : RegisterError(message) + data class InvalidDisplayName(override val message: String) : RegisterError(message) + data class Denied(override val message: String): RegisterError(message) + data class Unrecognized(override val message: String) : RegisterError(message) + data class Other(override val message: String, override val cause: Throwable? = null) : + RegisterError(message) +} + +sealed class GetPaymentDestinationError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Unrecognized : GetPaymentDestinationError() + class NotFound : GetPaymentDestinationError() + data class Other(override val cause: Throwable? = null) : GetPaymentDestinationError() +} + +sealed class GetUserFlagsError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Unrecognized : GetUserFlagsError() + class Denied : GetUserFlagsError() + data class Other(override val cause: Throwable? = null) : GetUserFlagsError() +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/ChatService.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/ChatService.kt new file mode 100644 index 000000000..b1d27a4d6 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/ChatService.kt @@ -0,0 +1,935 @@ +package xyz.flipchat.services.internal.network.service + +import com.codeinc.flipchat.gen.chat.v1.ChatService +import com.codeinc.flipchat.gen.chat.v1.ChatService.MemberUpdate +import com.codeinc.flipchat.gen.common.v1.Common +import com.codeinc.flipchat.gen.chat.v1.ChatService as ChatServiceRpc +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.model.ID +import com.getcode.model.KinAmount +import com.getcode.services.network.core.NetworkOracle +import com.getcode.services.observers.BidirectionalStreamReference +import com.getcode.utils.ErrorUtils +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import com.google.protobuf.Timestamp +import io.grpc.Status +import io.grpc.StatusRuntimeException +import io.grpc.stub.StreamObserver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import timber.log.Timber +import xyz.flipchat.services.data.ChatIdentifier +import xyz.flipchat.services.data.StartChatRequestType +import xyz.flipchat.services.domain.model.query.QueryOptions +import xyz.flipchat.services.internal.network.api.ChatApi +import xyz.flipchat.services.internal.network.chat.GetOrJoinChatResponse +import com.getcode.utils.FlipchatServerError +import xyz.flipchat.services.internal.network.utils.authenticate +import javax.inject.Inject + +typealias ChatHomeStreamReference = BidirectionalStreamReference + + +internal class ChatService @Inject constructor( + private val api: ChatApi, + private val networkOracle: NetworkOracle, +) { + suspend fun getChats( + owner: KeyPair, + queryOptions: QueryOptions = QueryOptions() + ): Result> { + return try { + networkOracle.managedRequest( + api.getChats( + owner = owner, + queryOptions = queryOptions, + ) + ) + .map { response -> + when (response.result) { + ChatServiceRpc.GetChatsResponse.Result.OK -> { + Result.success(response.chatsList) + } + + ChatServiceRpc.GetChatsResponse.Result.UNRECOGNIZED -> { + val error = GetChatsError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = GetChatsError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = GetChatsError.Other(cause = e) + Result.failure(error) + } + } + + suspend fun getChat( + owner: KeyPair, + identifier: ChatIdentifier, + ): Result { + return try { + networkOracle.managedRequest(api.getChat(owner, identifier)) + .map { response -> + when (response.result) { + ChatServiceRpc.GetChatResponse.Result.OK -> { + Result.success( + GetOrJoinChatResponse( + response.metadata, + response.membersList + ) + ) + } + + ChatServiceRpc.GetChatResponse.Result.UNRECOGNIZED -> { + val error = GetChatError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + + ChatServiceRpc.GetChatResponse.Result.NOT_FOUND -> { + val error = GetChatError.NotFound() + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = GetChatError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + e.printStackTrace() + val error = GetChatError.Other(cause = e) + Result.failure(error) + } + } + + suspend fun startChat( + owner: KeyPair, + type: StartChatRequestType, + ): Result { + return try { + networkOracle.managedRequest(api.startChat(owner, type)) + .map { response -> + when (response.result) { + ChatServiceRpc.StartChatResponse.Result.OK -> { + Result.success( + GetOrJoinChatResponse( + response.chat, + response.membersList + ) + ) + } + + ChatServiceRpc.StartChatResponse.Result.DENIED -> { + val error = StartChatError.Denied() + Timber.e(t = error) + Result.failure(error) + } + + ChatServiceRpc.StartChatResponse.Result.USER_NOT_FOUND -> { + val error = StartChatError.UserNotFound() + Timber.e(t = error) + Result.failure(error) + } + + ChatServiceRpc.StartChatResponse.Result.UNRECOGNIZED -> { + val error = StartChatError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = StartChatError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + e.printStackTrace() + val error = StartChatError.Other(cause = e) + Result.failure(error) + } + } + + suspend fun joinChat( + owner: KeyPair, + identifier: ChatIdentifier, + paymentId: ID?, + ): Result { + return try { + networkOracle.managedRequest(api.joinChat(owner, identifier, paymentId)) + .map { response -> + when (response.result) { + ChatServiceRpc.JoinChatResponse.Result.OK -> { + Result.success( + GetOrJoinChatResponse( + response.metadata, + response.membersList + ) + ) + } + + ChatServiceRpc.JoinChatResponse.Result.UNRECOGNIZED -> { + val error = JoinChatError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + + ChatServiceRpc.JoinChatResponse.Result.DENIED -> { + val error = JoinChatError.Denied() + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = JoinChatError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = JoinChatError.Other(cause = e) + Result.failure(error) + } + } + + suspend fun leaveChat( + owner: KeyPair, + chatId: ID, + ): Result { + return try { + networkOracle.managedRequest(api.leaveChat(owner, chatId)) + .map { response -> + when (response.result) { + ChatServiceRpc.LeaveChatResponse.Result.OK -> { + Result.success(Unit) + } + + ChatServiceRpc.LeaveChatResponse.Result.UNRECOGNIZED -> { + val error = LeaveChatError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = LeaveChatError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = LeaveChatError.Other(cause = e) + Result.failure(error) + } + } + + suspend fun setDisplayName( + owner: KeyPair, + chatId: ID, + displayName: String, + ): Result { + return try { + networkOracle.managedRequest(api.setDisplayName(owner, chatId, displayName)) + .map { response -> + when (response.result) { + ChatServiceRpc.SetDisplayNameResponse.Result.OK -> { + Result.success(Unit) + } + + ChatServiceRpc.SetDisplayNameResponse.Result.DENIED -> { + val error = SetRoomDisplayNameError.Denied() + Timber.e(t = error) + Result.failure(error) + } + + ChatServiceRpc.SetDisplayNameResponse.Result.CANT_SET -> { + val error = + SetRoomDisplayNameError.CantSet(response.alternateSuggestionsList.toList()) + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = SetRoomDisplayNameError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = SetRoomDisplayNameError.Other(cause = e) + Result.failure(error) + } + } + + suspend fun muteChat(owner: KeyPair, chatId: ID): Result { + return try { + networkOracle.managedRequest(api.muteChat(owner, chatId)) + .map { response -> + when (response.result) { + ChatServiceRpc.MuteChatResponse.Result.OK -> { + Result.success(Unit) + } + + ChatServiceRpc.MuteChatResponse.Result.UNRECOGNIZED -> { + val error = MuteChatStateError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + + ChatServiceRpc.MuteChatResponse.Result.DENIED -> { + val error = MuteChatStateError.Denied() + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = MuteChatStateError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = MuteChatStateError.Other(cause = e) + Result.failure(error) + } + } + + suspend fun unmuteChat(owner: KeyPair, chatId: ID): Result { + return try { + networkOracle.managedRequest(api.unmuteChat(owner, chatId)) + .map { response -> + when (response.result) { + ChatServiceRpc.UnmuteChatResponse.Result.OK -> { + Result.success(Unit) + } + + ChatServiceRpc.UnmuteChatResponse.Result.UNRECOGNIZED -> { + val error = MuteChatStateError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + + ChatServiceRpc.UnmuteChatResponse.Result.DENIED -> { + val error = MuteChatStateError.Denied() + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = MuteChatStateError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = MuteChatStateError.Other(cause = e) + Result.failure(error) + } + } + + @Deprecated("Replaced by setMessagingFee") + suspend fun setCoverCharge( + owner: KeyPair, + chatId: ID, + amount: KinAmount + ): Result { + return try { + networkOracle.managedRequest(api.setCoverCharge(owner, chatId, amount)) + .map { response -> + when (response.result) { + ChatServiceRpc.SetCoverChargeResponse.Result.OK -> { + Result.success(Unit) + } + + ChatServiceRpc.SetCoverChargeResponse.Result.UNRECOGNIZED -> { + val error = CoverChargeError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + + ChatServiceRpc.SetCoverChargeResponse.Result.DENIED -> { + val error = CoverChargeError.Denied() + Timber.e(t = error) + Result.failure(error) + } + + ChatServiceRpc.SetCoverChargeResponse.Result.CANT_SET -> { + val error = CoverChargeError.CantSet() + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = CoverChargeError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = MessagingFeeError.Other(cause = e) + Result.failure(error) + } + } + + suspend fun setMessagingFee( + owner: KeyPair, + chatId: ID, + amount: KinAmount + ): Result { + return try { + networkOracle.managedRequest(api.setMessagingFee(owner, chatId, amount)) + .map { response -> + when (response.result) { + ChatServiceRpc.SetMessagingFeeResponse.Result.OK -> { + Result.success(Unit) + } + + ChatServiceRpc.SetMessagingFeeResponse.Result.UNRECOGNIZED -> { + val error = MessagingFeeError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + + ChatServiceRpc.SetMessagingFeeResponse.Result.DENIED -> { + val error = MessagingFeeError.Denied() + Timber.e(t = error) + Result.failure(error) + } + + ChatServiceRpc.SetMessagingFeeResponse.Result.CANT_SET -> { + val error = MessagingFeeError.CantSet() + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = MessagingFeeError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = MessagingFeeError.Other(cause = e) + Result.failure(error) + } + } + + suspend fun getMemberUpdates( + owner: KeyPair, + chatId: ID, + afterMember: ID?, + ): Result> { + return try { + networkOracle.managedRequest(api.getMemberUpdates(owner, chatId, afterMember)) + .map { response -> + when (response.result) { + ChatServiceRpc.GetMemberUpdatesResponse.Result.OK -> { + Result.success(response.updatesList) + } + + ChatServiceRpc.GetMemberUpdatesResponse.Result.UNRECOGNIZED -> { + val error = GetMemberUpdateError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + + ChatServiceRpc.GetMemberUpdatesResponse.Result.NOT_FOUND -> { + val error = GetMemberUpdateError.NotFound() + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = GetMemberUpdateError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = GetMemberUpdateError.Other(cause = e) + Result.failure(error) + } + } + + suspend fun removeUser( + owner: KeyPair, + chatId: ID, + userId: ID + ): Result { + return try { + networkOracle.managedRequest(api.removeUser(owner, chatId, userId)) + .map { response -> + when (response.result) { + ChatServiceRpc.RemoveUserResponse.Result.OK -> { + Result.success(Unit) + } + + ChatServiceRpc.RemoveUserResponse.Result.UNRECOGNIZED -> { + val error = RemoveUserError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + + ChatServiceRpc.RemoveUserResponse.Result.DENIED -> { + val error = RemoveUserError.Denied() + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = RemoveUserError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = RemoveUserError.Other(cause = e) + Result.failure(error) + } + } + + suspend fun reportUser( + owner: KeyPair, + userId: ID, + messageId: ID + ): Result { + return try { + networkOracle.managedRequest(api.reportUser(owner, userId, messageId)) + .map { response -> + when (response.result) { + ChatServiceRpc.ReportUserResponse.Result.OK -> { + Result.success(Unit) + } + + ChatServiceRpc.ReportUserResponse.Result.UNRECOGNIZED -> { + val error = ReportUserError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = ReportUserError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = ReportUserError.Other(cause = e) + Result.failure(error) + } + } + + suspend fun muteUser( + owner: KeyPair, + chatId: ID, + userId: ID, + ): Result { + return try { + networkOracle.managedRequest(api.muteUser(owner, chatId, userId)) + .map { response -> + when (response.result) { + ChatServiceRpc.MuteUserResponse.Result.OK -> { + Result.success(Unit) + } + + ChatServiceRpc.MuteUserResponse.Result.UNRECOGNIZED -> { + val error = MuteUserError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = MuteUserError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = MuteUserError.Other(cause = e) + Result.failure(error) + } + } + + suspend fun promoteUser( + owner: KeyPair, + chatId: ID, + userId: ID, + enableSendPermission: Boolean = true, + ): Result { + return networkOracle.managedApiRequest( + call = { api.promoteUser(owner, chatId, userId, enableSendPermission) }, + handleResponse = { response -> + when (response.result) { + ChatService.PromoteUserResponse.Result.OK -> Result.success(Unit) + ChatService.PromoteUserResponse.Result.DENIED -> Result.failure(PromoteUserError.Denied()) + ChatService.PromoteUserResponse.Result.UNRECOGNIZED -> Result.failure(PromoteUserError.Unrecognized()) + else -> Result.failure(PromoteUserError.Other()) + } + }, + onOtherError = { cause -> + Result.failure(PromoteUserError.Other(cause)) + } + ) + } + + suspend fun demoteUser( + owner: KeyPair, + chatId: ID, + userId: ID, + disableSendPermission: Boolean = true, + ): Result { + return networkOracle.managedApiRequest( + call = { api.demoteUser(owner, chatId, userId, disableSendPermission) }, + handleResponse = { response -> + when (response.result) { + ChatService.DemoteUserResponse.Result.OK -> Result.success(Unit) + ChatService.DemoteUserResponse.Result.DENIED -> Result.failure(DemoteUserError.Denied()) + ChatService.DemoteUserResponse.Result.UNRECOGNIZED -> Result.failure(DemoteUserError.Unrecognized()) + else -> Result.failure(DemoteUserError.Other()) + } + }, + onOtherError = { cause -> + Result.failure(DemoteUserError.Other(cause)) + } + ) + } + + suspend fun enableChat( + owner: KeyPair, + chatId: ID, + ): Result { + return networkOracle.managedApiRequest( + call = { api.openChat(owner, chatId) }, + handleResponse = { response -> + when (response.result) { + ChatService.OpenChatResponse.Result.OK -> Result.success(Unit) + ChatService.OpenChatResponse.Result.DENIED -> Result.failure(OpenChatError.Denied()) + ChatService.OpenChatResponse.Result.UNRECOGNIZED -> Result.failure(OpenChatError.Unrecognized()) + else -> Result.failure(OpenChatError.Other()) + } + }, + onOtherError = { cause -> + Result.failure(OpenChatError.Other(cause = cause)) + } + ) + } + + suspend fun disableChat( + owner: KeyPair, + chatId: ID, + ): Result { + return networkOracle.managedApiRequest( + call = { api.closeChat(owner, chatId) }, + handleResponse = { response -> + when (response.result) { + ChatService.CloseChatResponse.Result.OK -> Result.success(Unit) + ChatService.CloseChatResponse.Result.DENIED -> Result.failure(CloseChatError.Denied()) + ChatService.CloseChatResponse.Result.UNRECOGNIZED -> Result.failure(CloseChatError.Unrecognized()) + else -> Result.failure(CloseChatError.Other()) + } + }, + onOtherError = { cause -> + Result.failure(CloseChatError.Other(cause = cause)) + } + ) + } + + fun openChatStream( + scope: CoroutineScope, + owner: KeyPair, + onEvent: (Result>) -> Unit + ): ChatHomeStreamReference { + trace("Chat Opening stream.") + val streamReference = ChatHomeStreamReference(scope) + streamReference.retain() + streamReference.timeoutHandler = { + trace("Chat Stream timed out") + openChatStream( + owner = owner, + reference = streamReference, + onEvent = onEvent + ) + } + + openChatStream(owner, streamReference, onEvent) + + return streamReference + } + + private fun openChatStream( + owner: KeyPair, + reference: ChatHomeStreamReference, + onEvent: (Result>) -> Unit + ) { + try { + reference.cancel() + reference.stream = + api.streamEvents(object : StreamObserver { + override fun onNext(value: ChatServiceRpc.StreamChatEventsResponse?) { + val result = value?.typeCase + if (result == null) { + trace( + message = "Chat Stream Server sent empty message. This is unexpected.", + type = TraceType.Error + ) + return + } + + when (result) { + ChatServiceRpc.StreamChatEventsResponse.TypeCase.EVENTS -> { + onEvent(Result.success(value.events.updatesList)) + } + + ChatServiceRpc.StreamChatEventsResponse.TypeCase.PING -> { + val stream = reference.stream ?: return + val request = ChatServiceRpc.StreamChatEventsRequest.newBuilder() + .setPong( + Common.ClientPong.newBuilder() + .setTimestamp( + Timestamp.newBuilder() + .setSeconds(System.currentTimeMillis() / 1_000) + ) + ).build() + + reference.receivedPing(updatedTimeout = value.ping.pingDelay.seconds * 1_000L) + stream.onNext(request) + trace("Pong Chat Stream Server timestamp: ${value.ping.timestamp}") + } + + ChatServiceRpc.StreamChatEventsResponse.TypeCase.TYPE_NOT_SET -> Unit + ChatServiceRpc.StreamChatEventsResponse.TypeCase.ERROR -> { + trace( + type = TraceType.Error, + message = "Chat Stream hit a snag. ${value.error.code}" + ) + } + } + } + + override fun onError(t: Throwable?) { + val statusException = t as? StatusRuntimeException + if (statusException?.status?.code == Status.Code.UNAVAILABLE) { + trace("Chat Reconnecting keepalive stream...") + openChatStream( + owner, + reference, + onEvent + ) + } else { + trace( + "Chat Stream ${statusException?.status?.code?.name}", + error = statusException?.status?.cause + ) + } + } + + override fun onCompleted() { + + } + }) + + reference.coroutineScope.launch { + val request = ChatServiceRpc.StreamChatEventsRequest.newBuilder() + .setParams( + ChatServiceRpc.StreamChatEventsRequest.Params.newBuilder() + .setTs( + Timestamp.newBuilder() + .setSeconds(System.currentTimeMillis() / 1_000) + ) + .apply { setAuth(authenticate(owner)) } + .build() + ).build() + + reference.stream?.onNext(request) + trace("Chat Stream Initiating a connection...") + } + } catch (e: Exception) { + if (e is IllegalStateException && e.message == "call already half-closed") { + // ignore + } else { + ErrorUtils.handleError(e) + } + } + } +} + +sealed class StartChatError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class UserNotFound : StartChatError() + class Denied : StartChatError() + class Unrecognized : StartChatError() + data class Other(override val cause: Throwable? = null) : StartChatError(cause = cause) +} + +sealed class GetChatsError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Unrecognized : GetChatsError() + data class Other(override val cause: Throwable? = null) : GetChatsError(cause = cause) +} + +sealed class JoinChatError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Unrecognized : JoinChatError() + class Denied : JoinChatError() + data class Other(override val cause: Throwable? = null) : JoinChatError(cause = cause) +} + +sealed class LeaveChatError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Unrecognized : LeaveChatError() + data class Other(override val cause: Throwable? = null) : LeaveChatError(cause = cause) +} + +sealed class MuteChatStateError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Unrecognized : MuteChatStateError() + class Denied : MuteChatStateError() + data class Other(override val cause: Throwable? = null) : MuteChatStateError(cause = cause) +} + +sealed class SetRoomDisplayNameError( + open val alternateSuggestions: List = emptyList(), + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + data class CantSet(override val alternateSuggestions: List) : + SetRoomDisplayNameError(alternateSuggestions) + + class Denied : SetRoomDisplayNameError() + data class Other(override val cause: Throwable? = null) : SetRoomDisplayNameError(cause = cause) +} + +sealed class GetChatError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class NotFound : GetChatError() + class Unrecognized : GetChatError() + data class Other(override val cause: Throwable? = null) : GetChatError(cause = cause) +} + +sealed class CoverChargeError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Unrecognized : CoverChargeError() + class Denied : CoverChargeError() + class CantSet : CoverChargeError() + data class Other(override val cause: Throwable? = null) : CoverChargeError(cause = cause) +} + +sealed class MessagingFeeError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Unrecognized : MessagingFeeError() + class Denied : MessagingFeeError() + class CantSet : MessagingFeeError() + data class Other(override val cause: Throwable? = null) : MessagingFeeError(cause = cause) +} + +sealed class RemoveUserError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Unrecognized : RemoveUserError() + class Denied : RemoveUserError() + data class Other(override val cause: Throwable? = null) : RemoveUserError(cause = cause) +} + +sealed class GetMemberUpdateError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Unrecognized : GetMemberUpdateError() + class NotFound : GetMemberUpdateError() + data class Other(override val cause: Throwable? = null) : GetMemberUpdateError(cause = cause) +} + +sealed class PromoteUserError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Denied : PromoteUserError() + class Unrecognized : PromoteUserError() + data class Other(override val cause: Throwable? = null) : PromoteUserError(cause = cause) +} + +sealed class DemoteUserError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Denied : DemoteUserError() + class Unrecognized : DemoteUserError() + data class Other(override val cause: Throwable? = null) : DemoteUserError(cause = cause) +} + +sealed class OpenChatError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Denied : OpenChatError() + class Unrecognized : OpenChatError() + data class Other(override val cause: Throwable? = null) : OpenChatError(cause = cause) +} + +sealed class CloseChatError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Denied : CloseChatError() + class Unrecognized : CloseChatError() + data class Other(override val cause: Throwable? = null) : CloseChatError(cause = cause) +} + + +sealed class MuteUserError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Unrecognized : MuteUserError() + data class Other(override val cause: Throwable? = null) : MuteUserError(cause = cause) +} + +sealed class ReportUserError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Unrecognized : ReportUserError() + data class Other(override val cause: Throwable? = null) : ReportUserError(cause = cause) +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/ManagedApiRequest.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/ManagedApiRequest.kt new file mode 100644 index 000000000..5adaa3766 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/ManagedApiRequest.kt @@ -0,0 +1,19 @@ +package xyz.flipchat.services.internal.network.service + +import com.getcode.services.network.core.NetworkOracle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +suspend fun NetworkOracle.managedApiRequest( + call: () -> Flow, + handleResponse: (ResponseType) -> Result, + onOtherError: (Exception) -> Result +): Result { + return try { + managedRequest(call()) + .map { response -> handleResponse(response) }.first() + } catch (e: Exception) { + onOtherError(e) + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/MessagingService.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/MessagingService.kt new file mode 100644 index 000000000..aee57ef24 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/MessagingService.kt @@ -0,0 +1,385 @@ +package xyz.flipchat.services.internal.network.service + +import com.codeinc.flipchat.gen.common.v1.Common +import com.codeinc.flipchat.gen.messaging.v1.MessagingService +import com.codeinc.flipchat.gen.messaging.v1.MessagingService.AdvancePointerResponse +import com.codeinc.flipchat.gen.messaging.v1.MessagingService.GetMessagesResponse +import com.codeinc.flipchat.gen.messaging.v1.MessagingService.StreamMessagesRequest.Params +import com.codeinc.flipchat.gen.messaging.v1.Model +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.model.ID +import com.getcode.model.chat.MessageStatus +import com.getcode.model.description +import com.getcode.services.model.chat.OutgoingMessageContent +import com.getcode.services.network.core.NetworkOracle +import com.getcode.services.observers.BidirectionalStreamReference +import com.getcode.utils.ErrorUtils +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import com.google.protobuf.Timestamp +import io.grpc.Status +import io.grpc.StatusRuntimeException +import io.grpc.stub.StreamObserver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import timber.log.Timber +import xyz.flipchat.services.domain.model.query.QueryOptions +import xyz.flipchat.services.internal.network.api.MessagingApi +import com.getcode.utils.FlipchatServerError +import xyz.flipchat.services.internal.network.extensions.toChatId +import xyz.flipchat.services.internal.network.extensions.toMessageId +import xyz.flipchat.services.internal.network.utils.authenticate +import javax.inject.Inject +import kotlin.coroutines.resume + +typealias ChatMessageStreamReference = BidirectionalStreamReference + + +internal class MessagingService @Inject constructor( + private val api: MessagingApi, + private val networkOracle: NetworkOracle, +) { + suspend fun getMessages( + owner: KeyPair, + chatId: ID, + queryOptions: QueryOptions, + ): Result> { + return try { + networkOracle.managedRequest( + api.getMessages(owner = owner, chatId = chatId, queryOptions = queryOptions) + ).map { response -> + when (response.result) { + GetMessagesResponse.Result.OK -> { + Result.success(response.messagesList) + } + + GetMessagesResponse.Result.UNRECOGNIZED -> { + val error = GetMessagesError.Unrecognized() + Result.failure(error) + } + + GetMessagesResponse.Result.DENIED -> { + val error = GetMessagesError.Denied() + Result.failure(error) + } + + else -> { + val error = GetMessagesError.Other() + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = GetMessagesError.Other(cause = e) + Result.failure(error) + } + } + + suspend fun sendMessage( + owner: KeyPair, + chatId: ID, + content: OutgoingMessageContent, + ): Result = suspendCancellableCoroutine { cont -> + try { + api.sendMessage( + owner = owner, + chatId = chatId, + content = content, + observer = object : StreamObserver { + override fun onNext(value: MessagingService.SendMessageResponse?) { + val requestResult = value?.result + if (requestResult == null) { + trace( + message = "Messaging SendMessage Server returned empty message. This is unexpected.", + type = TraceType.Error + ) + return + } + + val result = when (requestResult) { + MessagingService.SendMessageResponse.Result.OK -> { + trace("Chat message sent =: ${value.message.messageId.value.toList().description}") + Result.success(value.message) + } + + MessagingService.SendMessageResponse.Result.DENIED -> { + val error = SendMessageError.Denied() + Result.failure(error) + } + + MessagingService.SendMessageResponse.Result.UNRECOGNIZED -> { + val error = SendMessageError.Unrecognized() + Result.failure(error) + } + + else -> { + val error = SendMessageError.Other() + Result.failure(error) + } + } + + cont.resume(result) + } + + override fun onError(t: Throwable?) { + val error = SendMessageError.Other(t) + cont.resume(Result.failure(error)) + } + + override fun onCompleted() = Unit + } + ) + } catch (e: Exception) { + ErrorUtils.handleError(e) + cont.resume(Result.failure(e)) + } + } + + suspend fun advancePointer( + owner: KeyPair, + chatId: ID, + to: ID, + status: MessageStatus, + ): Result { + return try { + networkOracle.managedRequest(api.advancePointer(owner, chatId, to, status)) + .map { response -> + when (response.result) { + AdvancePointerResponse.Result.OK -> { + Result.success(Unit) + } + + AdvancePointerResponse.Result.UNRECOGNIZED -> { + val error = AdvancePointerError.Unrecognized() + Result.failure(error) + } + + AdvancePointerResponse.Result.DENIED -> { + val error = AdvancePointerError.Denied() + Result.failure(error) + } + + else -> { + val error = AdvancePointerError.Other() + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = AdvancePointerError.Other(cause = e) + Result.failure(error) + } + } + + suspend fun notifyIsTyping( + owner: KeyPair, + chatId: ID, + isTyping: Boolean + ): Result = suspendCancellableCoroutine { cont -> + try { + api.notifyIsTyping( + owner, + chatId, + isTyping, + observer = object : StreamObserver { + override fun onNext(value: MessagingService.NotifyIsTypingResponse?) { + val requestResult = value?.result + if (requestResult == null) { + trace( + message = "Messaging NotifyTyping Server returned empty message. This is unexpected.", + type = TraceType.Error + ) + return + } + + val result = when (requestResult) { + MessagingService.NotifyIsTypingResponse.Result.OK -> Result.success(Unit) + MessagingService.NotifyIsTypingResponse.Result.DENIED -> { + val error = TypingChangeError.Denied() + Result.failure(error) + } + + MessagingService.NotifyIsTypingResponse.Result.UNRECOGNIZED -> { + val error = TypingChangeError.Unrecognized() + Result.failure(error) + } + + else -> { + val error = TypingChangeError.Other() + Result.failure(error) + } + } + + cont.resume(result) + } + + override fun onError(t: Throwable?) { + val error = TypingChangeError.Other(cause = t) + cont.resume(Result.failure(error)) + } + + override fun onCompleted() = Unit + } + ) + } catch (e: Exception) { + val error = TypingChangeError.Other(cause = e) + cont.resume(Result.failure(error)) + } + } + + fun openMessageStream( + scope: CoroutineScope, + owner: KeyPair, + chatId: ID, + onEvent: (Result>) -> Unit + ): ChatMessageStreamReference { + trace("Message Opening stream.") + val streamReference = ChatMessageStreamReference(scope) + streamReference.retain() + streamReference.timeoutHandler = { + trace("Message Stream timed out") + openMessageStream( + owner = owner, + chatId = chatId, + reference = streamReference, + onEvent = onEvent + ) + } + + openMessageStream(owner, chatId, streamReference, onEvent) + + return streamReference + } + + private fun openMessageStream( + owner: KeyPair, + chatId: ID, + reference: ChatMessageStreamReference, + onEvent: (Result>) -> Unit + ) { + try { + reference.cancel() + reference.stream = + api.streamMessages(object : StreamObserver { + override fun onNext(value: MessagingService.StreamMessagesResponse?) { + val result = value?.typeCase + if (result == null) { + trace( + message = "Message Stream Server sent empty message. This is unexpected.", + type = TraceType.Error + ) + return + } + + when (result) { + MessagingService.StreamMessagesResponse.TypeCase.MESSAGES -> { + onEvent(Result.success(value.messages.messagesList)) + } + + MessagingService.StreamMessagesResponse.TypeCase.PING -> { + val stream = reference.stream ?: return + val request = MessagingService.StreamMessagesRequest.newBuilder() + .setParams(Params.newBuilder().setChatId(chatId.toChatId())) + .setPong( + Common.ClientPong.newBuilder() + .setTimestamp( + Timestamp.newBuilder() + .setSeconds(System.currentTimeMillis() / 1_000) + ) + ).build() + + reference.receivedPing(updatedTimeout = value.ping.pingDelay.seconds * 1_000L) + stream.onNext(request) + trace("Pong Message Stream Server timestamp: ${value.ping.timestamp}") + } + + MessagingService.StreamMessagesResponse.TypeCase.TYPE_NOT_SET -> Unit + MessagingService.StreamMessagesResponse.TypeCase.ERROR -> { + trace( + type = TraceType.Error, + message = "Message Stream hit a snag. ${value.error.code}" + ) + } + } + } + + override fun onError(t: Throwable?) { + val statusException = t as? StatusRuntimeException + if (statusException?.status?.code == Status.Code.UNAVAILABLE) { + trace("Message Stream Reconnecting keepalive stream...") + openMessageStream( + owner, + chatId, + reference, + onEvent + ) + } else { + t?.printStackTrace() + } + } + + override fun onCompleted() { + + } + }) + + reference.coroutineScope.launch { + val request = MessagingService.StreamMessagesRequest.newBuilder() + .setParams( + Params.newBuilder() + .setChatId(chatId.toChatId()) + .apply { setAuth(authenticate(owner)) } + ).build() + + reference.stream?.onNext(request) + trace("Message Stream Initiating a connection...") + } + } catch (e: Exception) { + if (e is IllegalStateException && e.message == "call already half-closed") { + // ignore + } else { + ErrorUtils.handleError(e) + } + } + } +} + +sealed class GetMessagesError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Unrecognized : GetMessagesError() + class Denied : GetMessagesError() + data class Other(override val cause: Throwable? = null) : GetMessagesError(cause = cause) +} + +sealed class AdvancePointerError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Unrecognized : AdvancePointerError() + class Denied : AdvancePointerError() + data class Other(override val cause: Throwable? = null) : AdvancePointerError(cause = cause) +} + +sealed class TypingChangeError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Unrecognized : AdvancePointerError() + class Denied : AdvancePointerError() + data class Other(override val cause: Throwable? = null) : AdvancePointerError(cause = cause) +} + +sealed class SendMessageError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Unrecognized : GetMessagesError() + class Denied : GetMessagesError() + class InvalidContentType : GetMessagesError() + data class Other(override val cause: Throwable? = null) : GetMessagesError(cause = cause) +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/ProfileService.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/ProfileService.kt new file mode 100644 index 000000000..f700b6c4b --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/ProfileService.kt @@ -0,0 +1,94 @@ +package xyz.flipchat.services.internal.network.service + +import com.codeinc.flipchat.gen.profile.v1.Model +import com.codeinc.flipchat.gen.profile.v1.ProfileService +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.model.ID +import com.getcode.services.network.core.NetworkOracle +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import timber.log.Timber +import xyz.flipchat.services.internal.network.api.ProfileApi +import com.getcode.utils.FlipchatServerError +import javax.inject.Inject + +internal class ProfileService @Inject constructor( + private val api: ProfileApi, + private val networkOracle: NetworkOracle, +) { + suspend fun getProfile(userId: ID): Result { + return try { + networkOracle.managedRequest(api.getProfile(userId)) + .map { + when (it.result) { + ProfileService.GetProfileResponse.Result.OK -> Result.success(it.userProfile) + ProfileService.GetProfileResponse.Result.NOT_FOUND -> { + val error = GetProfileError.NotFound() + Timber.e(t = error) + Result.failure(error) + } + ProfileService.GetProfileResponse.Result.UNRECOGNIZED -> { + val error = GetProfileError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + else -> { + val error = GetProfileError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = GetProfileError.Other(cause = e) + Result.failure(error) + } + } + + suspend fun setDisplayName(owner: KeyPair, displayName: String): Result { + return try { + networkOracle.managedRequest(api.setDisplayName(owner, displayName)) + .map { + when (it.result) { + ProfileService.SetDisplayNameResponse.Result.OK -> Result.success(Unit) + ProfileService.SetDisplayNameResponse.Result.INVALID_DISPLAY_NAME -> { + val error = SetUserDisplayNameError.InvalidDisplayName() + Timber.e(t = error) + Result.failure(error) + } + ProfileService.SetDisplayNameResponse.Result.UNRECOGNIZED -> { + val error = SetUserDisplayNameError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + else -> { + val error = SetUserDisplayNameError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = SetUserDisplayNameError.Other(cause = e) + Result.failure(error) + } + } +} + +sealed class GetProfileError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class NotFound : GetProfileError() + class Unrecognized : GetProfileError() + data class Other(override val cause: Throwable? = null) : GetProfileError(cause = cause) +} + +sealed class SetUserDisplayNameError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class InvalidDisplayName : SetUserDisplayNameError() + class Unrecognized : SetUserDisplayNameError() + data class Other(override val cause: Throwable? = null) : SetUserDisplayNameError(cause = cause) +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/PurchaseService.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/PurchaseService.kt new file mode 100644 index 000000000..85f4f1c9f --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/PurchaseService.kt @@ -0,0 +1,61 @@ +package xyz.flipchat.services.internal.network.service + +import com.codeinc.flipchat.gen.iap.v1.IapService +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.services.network.core.NetworkOracle +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import timber.log.Timber +import xyz.flipchat.services.internal.network.api.PurchaseApi +import com.getcode.utils.FlipchatServerError +import javax.inject.Inject + +internal class PurchaseService @Inject constructor( + private val api: PurchaseApi, + private val networkOracle: NetworkOracle, +) { + suspend fun onPurchaseCompleted(owner: KeyPair, receipt: String): Result { + return try { + networkOracle.managedRequest(api.onPurchaseCompleted(owner, receipt)) + .map { response -> + when (response.result) { + IapService.OnPurchaseCompletedResponse.Result.OK -> Result.success(Unit) + IapService.OnPurchaseCompletedResponse.Result.DENIED -> { + val error = PurchaseAckError.Denied() + Timber.e(t = error) + Result.failure(error) + } + IapService.OnPurchaseCompletedResponse.Result.INVALID_RECEIPT -> { + val error = PurchaseAckError.InvalidReceipt() + Timber.e(t = error) + Result.failure(error) + } + IapService.OnPurchaseCompletedResponse.Result.UNRECOGNIZED -> { + val error = PurchaseAckError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + else -> { + val error = PurchaseAckError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = PurchaseAckError.Other(e) + Timber.e(t = error) + Result.failure(error) + } + } +} + +sealed class PurchaseAckError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Unrecognized : PurchaseAckError() + class Denied : PurchaseAckError() + class InvalidReceipt: PurchaseAckError() + data class Other(override val cause: Throwable? = null) : PurchaseAckError(cause = cause) +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/PushService.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/PushService.kt new file mode 100644 index 000000000..f48874c7b --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/service/PushService.kt @@ -0,0 +1,121 @@ +package xyz.flipchat.services.internal.network.service + +import com.codeinc.flipchat.gen.push.v1.PushService +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.model.ID +import com.getcode.services.network.core.NetworkOracle +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import timber.log.Timber +import xyz.flipchat.services.internal.network.api.PushApi +import com.getcode.utils.FlipchatServerError +import javax.inject.Inject + +internal class PushService @Inject constructor( + private val api: PushApi, + private val networkOracle: NetworkOracle, +) { + suspend fun addToken( + owner: KeyPair, + token: String, + installationId: String? + ): Result { + return try { + networkOracle.managedRequest(api.addToken(owner, token, installationId)) + .map { + when (it.result) { + PushService.AddTokenResponse.Result.OK -> Result.success(Unit) + PushService.AddTokenResponse.Result.INVALID_PUSH_TOKEN -> { + val error = AddTokenError.InvalidPushToken() + Timber.e(t = error) + Result.failure(error) + } + PushService.AddTokenResponse.Result.UNRECOGNIZED -> { + val error = AddTokenError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + else -> { + val error = AddTokenError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = AddTokenError.Other(cause = e) + Result.failure(error) + } + } + + suspend fun deleteToken( + owner: KeyPair, + token: String, + ): Result { + return try { + networkOracle.managedRequest(api.deleteToken(owner, token)) + .map { + when (it.result) { + PushService.DeleteTokenResponse.Result.OK -> Result.success(Unit) + PushService.DeleteTokenResponse.Result.UNRECOGNIZED -> { + val error = DeleteTokenError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + else -> { + val error = DeleteTokenError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = DeleteTokenError.Other(cause = e) + Result.failure(error) + } + } + + suspend fun deleteTokens( + owner: KeyPair, + installationId: String?, + ): Result { + return try { + networkOracle.managedRequest(api.deleteTokens(owner, installationId)) + .map { + when (it.result) { + PushService.DeleteTokensResponse.Result.OK -> Result.success(Unit) + PushService.DeleteTokensResponse.Result.UNRECOGNIZED -> { + val error = DeleteTokenError.Unrecognized() + Timber.e(t = error) + Result.failure(error) + } + else -> { + val error = DeleteTokenError.Other() + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } catch (e: Exception) { + val error = DeleteTokenError.Other(cause = e) + Result.failure(error) + } + } +} + +sealed class AddTokenError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class InvalidPushToken : AddTokenError() + class Unrecognized : AddTokenError() + data class Other(override val cause: Throwable? = null) : AddTokenError(cause = cause) +} + +sealed class DeleteTokenError( + override val message: String? = null, + override val cause: Throwable? = null +) : FlipchatServerError(message, cause) { + class Unrecognized : DeleteTokenError() + data class Other(override val cause: Throwable? = null) : DeleteTokenError(cause = cause) +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/utils/AuthenticateMessage.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/utils/AuthenticateMessage.kt new file mode 100644 index 000000000..c60db6441 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/utils/AuthenticateMessage.kt @@ -0,0 +1,29 @@ +package xyz.flipchat.services.internal.network.utils + +import com.codeinc.flipchat.gen.common.v1.Common +import com.getcode.ed25519.Ed25519 +import com.google.protobuf.GeneratedMessageLite +import xyz.flipchat.services.internal.network.extensions.asPublicKey +import xyz.flipchat.services.internal.network.extensions.toSignature +import java.io.ByteArrayOutputStream + +internal fun , B : GeneratedMessageLite.Builder> GeneratedMessageLite.Builder.authenticate(owner: Ed25519.KeyPair): Common.Auth { + // dump message up until this point into a ByteArray + val bos = ByteArrayOutputStream() + this.buildPartial().writeTo(bos) + + /** + * sign message up to this point with owner and convert to [com.codeinc.flipchat.gen.common.v1.Signature] + */ + val signature = Ed25519.sign(bos.toByteArray(), owner).toSignature() + // build Auth.Keypair sub model + val keyPairModel = Common.Auth.KeyPair.newBuilder() + .setPubKey(owner.asPublicKey()) + .apply { setSignature(signature) } + .build() + + // return Auth model + return Common.Auth.newBuilder() + .setKeyPair(keyPairModel) + .build() +} diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/utils/SignMessage.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/utils/SignMessage.kt new file mode 100644 index 000000000..c4bf711c0 --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/network/utils/SignMessage.kt @@ -0,0 +1,18 @@ +package xyz.flipchat.services.internal.network.utils + +import com.codeinc.flipchat.gen.common.v1.Common +import com.getcode.ed25519.Ed25519 +import com.google.protobuf.GeneratedMessageLite +import xyz.flipchat.services.internal.network.extensions.toSignature +import java.io.ByteArrayOutputStream + +internal fun , B : GeneratedMessageLite.Builder> GeneratedMessageLite.Builder.sign(owner: Ed25519.KeyPair): Common.Signature { + // dump message up until this point into a ByteArray + val bos = ByteArrayOutputStream() + this.buildPartial().writeTo(bos) + + /** + * sign message up to this point with owner and return as [com.codeinc.flipchat.gen.common.v1.Signature] + */ + return Ed25519.sign(bos.toByteArray(), owner).toSignature() +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/protomapping/MessageContent.kt b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/protomapping/MessageContent.kt new file mode 100644 index 000000000..6c13ad8eb --- /dev/null +++ b/services/flipchat/chat/src/main/kotlin/xyz/flipchat/services/internal/protomapping/MessageContent.kt @@ -0,0 +1,63 @@ +package xyz.flipchat.services.internal.protomapping + +import com.codeinc.flipchat.gen.messaging.v1.Model +import com.getcode.model.ID +import com.getcode.model.chat.AnnouncementAction +import com.getcode.model.chat.MessageContent +import xyz.flipchat.services.internal.data.mapper.ifZeroOrElse + +operator fun MessageContent.Companion.invoke( + proto: Model.Content, + senderId: ID, + isFromSelf: Boolean = false, +): MessageContent? { + return when (proto.typeCase) { + Model.Content.TypeCase.LOCALIZED_ANNOUNCEMENT -> MessageContent.Announcement( + isFromSelf = isFromSelf, + value = proto.localizedAnnouncement.keyOrText + ) + + Model.Content.TypeCase.ACTIONABLE_ANNOUNCEMENT -> MessageContent.ActionableAnnouncement( + isFromSelf = isFromSelf, + keyOrText = proto.actionableAnnouncement.keyOrText, + action = when (proto.actionableAnnouncement.action.typeCase) { + Model.ActionableAnnouncementContent.Action.TypeCase.SHARE_ROOM_LINK -> AnnouncementAction.Share + Model.ActionableAnnouncementContent.Action.TypeCase.TYPE_NOT_SET, + null -> AnnouncementAction.Unknown + } + ) + + Model.Content.TypeCase.TEXT -> MessageContent.RawText( + isFromSelf = isFromSelf, + value = proto.text.text + ) + + Model.Content.TypeCase.REACTION -> MessageContent.Reaction( + emoji = proto.reaction.emoji, + originalMessageId = proto.reaction.originalMessageId.value.toList(), + isFromSelf = isFromSelf + ) + + Model.Content.TypeCase.REPLY -> MessageContent.Reply( + text = proto.reply.replyText, + originalMessageId = proto.reply.originalMessageId.value.toList(), + isFromSelf = isFromSelf + ) + + Model.Content.TypeCase.DELETED -> MessageContent.DeletedMessage( + originalMessageId = proto.deleted.originalMessageId.value.toList(), + messageDeleter = senderId, + isFromSelf = isFromSelf + ) + + Model.Content.TypeCase.TIP -> MessageContent.MessageTip( + originalMessageId = proto.tip.originalMessageId.value.toList(), + tipperId = senderId, + isFromSelf = isFromSelf, + amountInQuarks = proto.tip.tipAmount.quarks / 100_000 + ) + + Model.Content.TypeCase.TYPE_NOT_SET -> return null + else -> return null + } +} \ No newline at end of file diff --git a/services/flipchat/chat/src/main/res/values/strings.xml b/services/flipchat/chat/src/main/res/values/strings.xml new file mode 100644 index 000000000..f38e17280 --- /dev/null +++ b/services/flipchat/chat/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + Room #%1$s + #%1$s + #%1$s: %2$s + \ No newline at end of file diff --git a/services/flipchat/core/.gitignore b/services/flipchat/core/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/services/flipchat/core/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/services/flipchat/core/build.gradle.kts b/services/flipchat/core/build.gradle.kts new file mode 100644 index 000000000..51a11f189 --- /dev/null +++ b/services/flipchat/core/build.gradle.kts @@ -0,0 +1,97 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.flipchatNamespace}.services.core" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + + consumerProguardFiles("consumer-rules.pro") + + buildConfigField("String", "VERSION_NAME", "\"${Packaging.Flipchat.versionName}\"") + + buildConfigField("Boolean", "NOTIFY_ERRORS", "false") + buildConfigField( + "String", + "GOOGLE_CLOUD_PROJECT_NUMBER", + "\"${tryReadProperty(rootProject.rootDir, "GOOGLE_CLOUD_PROJECT_NUMBER", "-1L")}\"" + ) + + buildConfigField( + "String", + "FINGERPRINT_API_KEY", + "\"${tryReadProperty(rootProject.rootDir, "FINGERPRINT_API_KEY")}\"" + ) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } + + buildFeatures { + buildConfig = true + } +} + +dependencies { + implementation(project(":definitions:code-vm:models")) + api(project(":services:shared")) + implementation(project(":ui:resources")) + + implementation(Libs.kotlinx_coroutines_core) + implementation(Libs.kotlinx_serialization_json) + implementation(Libs.inject) + + implementation(Libs.grpc_android) + implementation(Libs.grpc_okhttp) + implementation(Libs.grpc_kotlin) + implementation(Libs.androidx_lifecycle_runtime) + implementation(Libs.okhttp) + implementation(Libs.mixpanel) + + implementation(platform(Libs.firebase_bom)) + implementation(Libs.firebase_crashlytics) + implementation(Libs.firebase_installations) + implementation(Libs.firebase_perf) + implementation(Libs.firebase_messaging) + + implementation(Libs.play_integrity) + + implementation(Libs.androidx_paging_runtime) + + kapt(Libs.androidx_room_compiler) + implementation(Libs.sqlcipher) + + implementation(Libs.fingerprint_pro) + + implementation(Libs.lib_phone_number_google) + + androidTestImplementation(Libs.androidx_junit) + androidTestImplementation(Libs.junit) + androidTestImplementation(Libs.androidx_test_runner) + + implementation(Libs.hilt) + kapt(Libs.hilt_android_compiler) + kapt(Libs.hilt_compiler) + + implementation(Libs.timber) + implementation(Libs.bugsnag) +} diff --git a/services/flipchat/core/consumer-rules.pro b/services/flipchat/core/consumer-rules.pro new file mode 100644 index 000000000..5f642a47c --- /dev/null +++ b/services/flipchat/core/consumer-rules.pro @@ -0,0 +1,6 @@ +# Needed to keep generic signatures +-keepattributes Signature + +-keepclasseswithmembernames class * { + native ; +} \ No newline at end of file diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/generator/Generator.kt b/services/flipchat/core/src/main/kotlin/com/getcode/generator/Generator.kt new file mode 100644 index 000000000..e21ce8993 --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/generator/Generator.kt @@ -0,0 +1,5 @@ +package com.getcode.generator + +interface Generator { + fun generate(predicate: D): R +} \ No newline at end of file diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/generator/MnemonicGenerator.kt b/services/flipchat/core/src/main/kotlin/com/getcode/generator/MnemonicGenerator.kt new file mode 100644 index 000000000..f2ca12d11 --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/generator/MnemonicGenerator.kt @@ -0,0 +1,18 @@ +package com.getcode.generator + +import com.getcode.crypt.MnemonicPhrase +import com.getcode.services.utils.Base58String +import com.getcode.services.utils.Base64String +import javax.inject.Inject + +class MnemonicGenerator @Inject constructor( +): Generator { + + override fun generate(predicate: Base64String): MnemonicPhrase { + return MnemonicPhrase.fromEntropyB64(predicate) + } + + fun generateFromBase58(predicate: Base58String): MnemonicPhrase { + return MnemonicPhrase.fromEntropyB58(predicate) + } +} \ No newline at end of file diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/generator/OrganizerGenerator.kt b/services/flipchat/core/src/main/kotlin/com/getcode/generator/OrganizerGenerator.kt new file mode 100644 index 000000000..f2333ef54 --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/generator/OrganizerGenerator.kt @@ -0,0 +1,12 @@ +package com.getcode.generator + +import com.getcode.crypt.MnemonicPhrase +import com.getcode.solana.organizer.Organizer +import javax.inject.Inject + +class OrganizerGenerator @Inject constructor(): Generator { + + override fun generate(predicate: MnemonicPhrase): Organizer { + return Organizer.newInstance(predicate) + } +} \ No newline at end of file diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/model/AccountInfo.kt b/services/flipchat/core/src/main/kotlin/com/getcode/model/AccountInfo.kt new file mode 100644 index 000000000..e74014d5c --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/model/AccountInfo.kt @@ -0,0 +1,268 @@ +package com.getcode.model + +import com.codeinc.gen.account.v1.CodeAccountService as AccountService +import com.getcode.solana.organizer.AccountType + +data class AccountInfo ( + /// The account's derivation index for applicable account types. When this field + /// doesn't apply, a zero value is provided. + var index: Int, + + /// The type of token account, which infers its intended use. + var accountType: AccountType, + + /// The token account's address + var address: com.getcode.solana.keys.PublicKey, + + /// The owner of the token account, which can also be thought of as a parent + /// account that links to one or more token accounts. This is provided when + /// available. + var owner: com.getcode.solana.keys.PublicKey?, + + /// The token account's authority, which has access to moving funds for the + /// account. This can be the owner account under certain circumstances (eg. + /// ATA, primary account). This is provided when available. + var authority: com.getcode.solana.keys.PublicKey?, + + /// The source of truth for the balance calculation. + var balanceSource: BalanceSource, + + /// The Kin balance in quarks, as observed by Code. This may not reflect the + /// value on the blockchain and could be non-zero even if the account hasn't + /// been created. Use balance_source to determine how this value was calculated. + var balance: Kin, + + /// The state of the account as it pertains to Code's ability to manage funds. + var managementState: ManagementState, + + /// The state of the account on the blockchain. + var blockchainState: BlockchainState, + + /// Whether an account is claimed. This only applies to relevant account types + /// (eg. REMOTE_SEND_GIFT_CARD). + var claimState: ClaimState, + + /// For temporary incoming accounts only. Flag indicates whether client must + /// actively try rotating it by issuing a ReceivePayments intent. In general, + /// clients should wait as long as possible until this flag is true or requiring + /// the funds to send their next payment. + var mustRotate: Boolean, + + /// For account types used as an intermediary for sending money between two + /// users (eg. REMOTE_SEND_GIFT_CARD), this represents the original exchange + /// data used to fund the account. Over time, this value will become stale: + /// 1. Exchange rates will fluctuate, so the total fiat amount will differ. + /// 2. External entities can deposit additional funds into the account, so + /// the balance, in quarks, may be greater than the original quark value. + /// 3. The balance could have been received, so the total balance can show + /// as zero. + var originalKinAmount: KinAmount?, + + /// The relationship with a third party that this account has established with. + /// This only applies to relevant account types (eg. RELATIONSHIP). + var relationship: Relationship?, + + // Time the account was created, if available. For Code accounts, this is + // the time of intent submission. Otherwise, for external accounts, it is + // the time created on the blockchain. + var createdAt: Long?, + + ) { + companion object { + fun newInstance(info: AccountService.TokenAccountInfo): AccountInfo? { + val accountType = AccountType.newInstance(info.accountType, info.relationship) ?: return null + val address = + com.getcode.solana.keys.PublicKey(info.address.value.toByteArray().toList()) + val balanceSource = BalanceSource.getInstance(info.balanceSource) ?: return null + + val managementState = ManagementState.getInstance(info.managementState) ?: return null + val blockchainState = BlockchainState.getInstance(info.blockchainState) ?: return null + val claimState = ClaimState.getInstance(info.claimState) ?: return null + + val owner = com.getcode.solana.keys.PublicKey(info.owner.value.toByteArray().toList()) + val authority = + com.getcode.solana.keys.PublicKey(info.authority.value.toByteArray().toList()) + + val originalCurrency = CurrencyCode.tryValueOf(info.originalExchangeData.currency) + + val originalKinAmount = originalCurrency?.let { + KinAmount.newInstance( + kin = Kin(info.originalExchangeData.quarks), + rate = Rate( + fx = info.originalExchangeData.exchangeRate, + currency = originalCurrency + ) + ) + } + + val relationship = Domain.from(info.relationship.domain.value) + ?.let { Relationship(it) } + + return AccountInfo( + index = info.index.toInt(), + accountType = accountType, + address = address, + owner = owner, + authority = authority, + balanceSource = balanceSource, + balance = Kin(info.balance), + managementState = managementState, + blockchainState = blockchainState, + claimState = claimState, + mustRotate = info.mustRotate, + originalKinAmount = originalKinAmount, + relationship = relationship, + createdAt = info.createdAt.seconds * 1000L + ) + } + } + + enum class ManagementState { + /// The state of the account is unknown. This may be returned when the + /// data source is unstable and a reliable state cannot be determined. + Unknown, + + /// Code does not maintain a management state and won't move funds for this + /// account. + None, + + /// The account is in the process of transitioning to the LOCKED state. + Locking, + + /// The account's funds are locked and Code has co-signing authority. + Locked, + + /// The account is in the process of transitioning to the UNLOCKED state. + Unlocking, + + /// The account's funds are unlocked and Code no longer has co-signing + /// authority. The account must transition to the LOCKED state to have + /// management capabilities. + Unlocked, + + /// The account is in the process of transitioning to the CLOSED state. + Closing, + + /// The account has been closed and doesn't exist on the blockchain. + /// Subsequently, it also has a zero balance. + Closed; + + companion object { + fun getInstance(state: AccountService.TokenAccountInfo.ManagementState): ManagementState? { + return when (state) { + AccountService.TokenAccountInfo.ManagementState.MANAGEMENT_STATE_UNKNOWN -> Unknown + AccountService.TokenAccountInfo.ManagementState.MANAGEMENT_STATE_NONE -> None + AccountService.TokenAccountInfo.ManagementState.MANAGEMENT_STATE_LOCKING -> Locking + AccountService.TokenAccountInfo.ManagementState.MANAGEMENT_STATE_LOCKED -> Locked + AccountService.TokenAccountInfo.ManagementState.MANAGEMENT_STATE_UNLOCKING -> Unlocking + AccountService.TokenAccountInfo.ManagementState.MANAGEMENT_STATE_UNLOCKED -> Unlocked + AccountService.TokenAccountInfo.ManagementState.MANAGEMENT_STATE_CLOSING -> Closing + AccountService.TokenAccountInfo.ManagementState.MANAGEMENT_STATE_CLOSED -> Closed + AccountService.TokenAccountInfo.ManagementState.UNRECOGNIZED -> null + } + + } + } + } + + enum class BlockchainState { + /// The state of the account is unknown. This may be returned when the + /// data source is unstable and a reliable state cannot be determined. + Unknown, + + /// The account does not exist on the blockchain. + DoesntExist, + + /// The account is created and exists on the blockchain. + Exists; + + companion object { + fun getInstance(state: AccountService.TokenAccountInfo.BlockchainState): BlockchainState? { + return when (state) { + AccountService.TokenAccountInfo.BlockchainState.BLOCKCHAIN_STATE_UNKNOWN -> Unknown + AccountService.TokenAccountInfo.BlockchainState.BLOCKCHAIN_STATE_DOES_NOT_EXIST -> DoesntExist + AccountService.TokenAccountInfo.BlockchainState.BLOCKCHAIN_STATE_EXISTS -> Exists + AccountService.TokenAccountInfo.BlockchainState.UNRECOGNIZED -> null + } + } + } + } + + enum class ClaimState { + /// could not be fetched by server. + Unknown, + + /// The account has not yet been claimed. + NotClaimed, + + /// The account is claimed. Attempting to claim it will fail. + Claimed, + + /// The account hasn't been claimed, but is expired. Funds will move + /// back to the issuer. Attempting to claim it will fail. + Expired; + + companion object { + fun getInstance(state: AccountService.TokenAccountInfo.ClaimState): ClaimState? { + return when (state) { + AccountService.TokenAccountInfo.ClaimState.CLAIM_STATE_UNKNOWN -> Unknown + AccountService.TokenAccountInfo.ClaimState.CLAIM_STATE_NOT_CLAIMED -> NotClaimed + AccountService.TokenAccountInfo.ClaimState.CLAIM_STATE_CLAIMED -> Claimed + AccountService.TokenAccountInfo.ClaimState.CLAIM_STATE_EXPIRED -> Expired + AccountService.TokenAccountInfo.ClaimState.UNRECOGNIZED -> null + } + } + } + } + + enum class BalanceSource { + /// The account's balance could not be determined. This may be returned when + /// the data source is unstable and a reliable balance cannot be determined. + Unknown, + + /// The account's balance was fetched directly from a finalized state on the + /// blockchain. + Blockchain, + + /// The account's balance was calculated using cached values in Code. Accuracy + /// is only guaranteed when management_state is LOCKED. + Cache; + + companion object { + fun getInstance(source: AccountService.TokenAccountInfo.BalanceSource): BalanceSource? { + return when (source) { + AccountService.TokenAccountInfo.BalanceSource.BALANCE_SOURCE_UNKNOWN -> Unknown + AccountService.TokenAccountInfo.BalanceSource.BALANCE_SOURCE_BLOCKCHAIN -> Blockchain + AccountService.TokenAccountInfo.BalanceSource.BALANCE_SOURCE_CACHE -> Cache + AccountService.TokenAccountInfo.BalanceSource.UNRECOGNIZED -> null + } + } + + } + } + + data class Relationship(val domain: Domain) +} + +val AccountInfo.displayName: String + get() = when (val type = accountType) { + is AccountType.Bucket -> type.type.name.replace("Bucket", "") + AccountType.Incoming -> "Incoming $index" + AccountType.Outgoing -> "Outgoing $index" + AccountType.Primary -> "Primary" + AccountType.RemoteSend -> "Remote Send" + is AccountType.Relationship -> type.domain.relationshipHost + AccountType.Swap -> "Swap (USDC)" +} + +// An account is deemed unuseable in Code if the management +// state for said account is no longer `locked`. Some accounts may +// be allowed to operated in an 'unlocked' or another state +val AccountInfo.unusable: Boolean + get() = if (managementState == AccountInfo.ManagementState.None) { + // If the account is not managed + // by Code, it is always useable + false + } else { + managementState != AccountInfo.ManagementState.Locked + } diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/model/RelationshipBox.kt b/services/flipchat/core/src/main/kotlin/com/getcode/model/RelationshipBox.kt new file mode 100644 index 000000000..19c77304b --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/model/RelationshipBox.kt @@ -0,0 +1,33 @@ +package com.getcode.model + +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.organizer.Relationship +import okhttp3.internal.toImmutableMap +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RelationshipBox @Inject constructor() { + private val _publicKeys = mutableMapOf() + val publicKeys + get() = _publicKeys.toImmutableMap() + + private val _domains = mutableMapOf() + val domains + get() = _domains.toImmutableMap() + + + fun relationships(largestFirst: Boolean = false): List { + return _domains.values.sortedWith { a, b -> + val comparisonResult = a.partialBalance.compareTo(b.partialBalance) + if (largestFirst) -comparisonResult else comparisonResult + } + } + fun relationshipWith(publicKey: PublicKey) = _publicKeys[publicKey] + fun relationshipWith(domain: Domain) = _domains[domain.relationshipHost] + + fun insert(relationship: Relationship) { + _publicKeys[relationship.getCluster().vaultPublicKey] = relationship + _domains[relationship.domain.relationshipHost] = relationship + } +} \ No newline at end of file diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/model/extensions/AssociatedTokenAccount.kt b/services/flipchat/core/src/main/kotlin/com/getcode/model/extensions/AssociatedTokenAccount.kt new file mode 100644 index 000000000..25207b8c1 --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/model/extensions/AssociatedTokenAccount.kt @@ -0,0 +1,15 @@ +package com.getcode.model.extensions + +import com.getcode.solana.keys.AssociatedTokenAccount +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey + +fun AssociatedTokenAccount.Companion.newInstance( + owner: PublicKey, + mint: Mint +): AssociatedTokenAccount { + return AssociatedTokenAccount( + owner = owner, + ata = PublicKey.deriveAssociatedAccount(owner = owner, mint = mint) + ) +} \ No newline at end of file diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/model/extensions/MemoProgram_Memo.kt b/services/flipchat/core/src/main/kotlin/com/getcode/model/extensions/MemoProgram_Memo.kt new file mode 100644 index 000000000..a05af2ca2 --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/model/extensions/MemoProgram_Memo.kt @@ -0,0 +1,12 @@ +package com.getcode.model.extensions + +import com.getcode.model.SocialUser +import com.getcode.solana.instructions.programs.MemoProgram_Memo + +fun MemoProgram_Memo.Companion.newInstance(tipMetadata: SocialUser): MemoProgram_Memo { + val memo = "tip:${tipMetadata.platform}:${tipMetadata.username}" + + return MemoProgram_Memo( + memo.toByteArray().toList() + ) +} diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/model/extensions/PreSwapStateAccount.kt b/services/flipchat/core/src/main/kotlin/com/getcode/model/extensions/PreSwapStateAccount.kt new file mode 100644 index 000000000..58768c104 --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/model/extensions/PreSwapStateAccount.kt @@ -0,0 +1,16 @@ +package com.getcode.model.extensions + +import com.getcode.solana.keys.PreSwapStateAccount +import com.getcode.solana.keys.PublicKey + +fun PreSwapStateAccount.Companion.newInstance( + owner: PublicKey, + source: PublicKey, + destination: PublicKey, + nonce: PublicKey +): PreSwapStateAccount { + return PreSwapStateAccount( + owner = owner, + state = PublicKey.derivePreSwapState(source, destination, nonce) + ) +} \ No newline at end of file diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/model/extensions/PublicKey.kt b/services/flipchat/core/src/main/kotlin/com/getcode/model/extensions/PublicKey.kt new file mode 100644 index 000000000..bfaea0f2e --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/model/extensions/PublicKey.kt @@ -0,0 +1,198 @@ +package com.getcode.model.extensions + +import com.getcode.crypt.Sha256Hash +import com.getcode.ed25519.Ed25519 +import com.getcode.model.Kin +import com.getcode.solana.builder.vmTimeAuthority +import com.getcode.solana.instructions.programs.* +import com.getcode.solana.keys.Hash +import com.getcode.solana.keys.Key32.Companion.splitter +import com.getcode.solana.keys.Key32.Companion.subsidizer +import com.getcode.solana.keys.ProgramDerivedAccount +import com.getcode.solana.keys.PublicKey +import org.kin.sdk.base.tools.longToByteArray +import java.io.ByteArrayOutputStream +import java.io.IOException + +fun PublicKey.Companion.deriveAssociatedAccount(owner: PublicKey, mint: PublicKey): ProgramDerivedAccount { + return findProgramAddress( + seeds = listOf(owner.bytes.toByteArray(), TokenProgram.address.bytes.toByteArray(), mint.bytes.toByteArray()), + programId = AssociatedTokenProgram.address, + ) +} + +fun PublicKey.Companion.deriveTimelockStateAccount( + owner: PublicKey, + lockout: Long +): ProgramDerivedAccount { + val seeds: List = listOf( + "timelock_state".toByteArray(Charsets.UTF_8), + kin.bytes.toByteArray(), + vmTimeAuthority.bytes.toByteArray(), + owner.bytes.toByteArray(), + byteArrayOf(lockout.toByte()) + ) + + return findProgramAddress( + seeds = seeds, + programId = TimelockProgram.address, + ) +} + +fun PublicKey.Companion.deriveTimelockVaultAccount( + stateAccount: PublicKey, + version: Long +): ProgramDerivedAccount { + val seeds: List = listOf( + "timelock_vault".toByteArray(Charsets.UTF_8), + stateAccount.bytes.toByteArray(), + byteArrayOf(version.toByte()) + ) + + return findProgramAddress( + seeds = seeds, + programId = TimelockProgram.address, + ) +} + +fun PublicKey.Companion.deriveLegacyTimelockStateAccount( + owner: PublicKey, + lockout: Long +): ProgramDerivedAccount { + val nonce = SystemProgram.address + val version = byteArrayOf(1) + val pdaPadding = SystemProgram.address + + val seeds: List = listOf( + "timelock_state".toByteArray(Charsets.UTF_8), + version, + kin.bytes.toByteArray(), + subsidizer.bytes.toByteArray(), + nonce.bytes.toByteArray(), + owner.bytes.toByteArray(), + lockout.longToByteArray(), + pdaPadding.bytes.toByteArray() + ) + + return findProgramAddress( + seeds = seeds, + programId = TimelockProgram.legacyAddress, + ) +} + +fun PublicKey.Companion.deriveLegacyTimelockVaultAccount( + stateAccount: PublicKey +): ProgramDerivedAccount { + val seeds: List = listOf( + "timelock_vault".toByteArray(Charsets.UTF_8), + stateAccount.bytes.toByteArray(), + byteArrayOf(0) + ) + + return findProgramAddress( + seeds = seeds, + programId = TimelockProgram.legacyAddress, + ) +} + +/// FindProgramAddress mirrors the implementation of the Solana SDK's FindProgramAddress. Its primary +/// use case (for Kin and Agora) is for deriving associated accounts. +/// +/// Reference: https://github.com/solana-labs/solana/blob/5548e599fe4920b71766e0ad1d121755ce9c63d5/sdk/program/src/pubkey.rs#L234 +/// +fun PublicKey.Companion.findProgramAddress( + seeds: List, + programId: PublicKey +): ProgramDerivedAccount { + for (i in 0..255) { + val bumpValue = 255 - i + try { + val publicKey = deriveProgramAddress(programId, listOf(*seeds.toTypedArray(), byteArrayOf(bumpValue.toByte()))) + return ProgramDerivedAccount(publicKey, bumpValue) + } catch (e: RuntimeException) { + //no-op + } + } + + throw Exception("Unable to find a viable program address nonce") +} + +/// CreateProgramAddress mirrors the implementation of the Solana SDK's CreateProgramAddress. +/// +/// ProgramAddresses are public keys that _do not_ lie on the ed25519 curve to ensure that +/// there is no associated private key. In the event that the program and seed parameters +/// result in a valid public key, ErrInvalidPublicKey is returned. +/// +/// Reference: https://github.com/solana-labs/solana/blob/5548e599fe4920b71766e0ad1d121755ce9c63d5/sdk/program/src/pubkey.rs#L158 +/// +fun PublicKey.Companion.deriveProgramAddress(programId: PublicKey, seeds: List): PublicKey { + fun PublicKey.Companion.getMaxSeeds() = 16 + + val buffer = ByteArrayOutputStream() + require(seeds.size < getMaxSeeds()) { "Max seed size exceeded" } + + for (seed in seeds) { + try { + buffer.write(seed) + } catch (e: IOException) { + throw RuntimeException(e) + } + } + try { + buffer.write(programId.bytes.toByteArray()) + buffer.write("ProgramDerivedAddress".toByteArray()) + } catch (e: IOException) { + throw RuntimeException(e) + } + val hash = Sha256Hash.hash(buffer.toByteArray()) + + val publicKey = PublicKey(hash.toList()) + + // Following the Solana SDK, we want to _reject_ the generated public key + // if it's a valid compressed EdwardsPoint (on the curve). + // + if (Ed25519.onCurve(publicKey.bytes.toByteArray())) { + throw RuntimeException("Invalid seeds, address must fall off the curve") + } + + return PublicKey(hash.toList()) +} + +fun PublicKey.Companion.deriveCommitmentStateAccount(treasury: PublicKey, recentRoot: Hash, transcript: Hash, destination: PublicKey, amount: Kin): ProgramDerivedAccount { + return findProgramAddress( + programId = splitter, + seeds = listOf( + "commitment_state".toByteArray(Charsets.UTF_8), + treasury.bytes.toByteArray(), + recentRoot.bytes.toByteArray(), + transcript.bytes.toByteArray(), + destination.bytes.toByteArray(), + amount.quarks.longToByteArray() + ) + ) +} + +fun PublicKey.Companion.deriveCommitmentVaultAccount(treasury: PublicKey, commitmentState: PublicKey): ProgramDerivedAccount { + return findProgramAddress( + programId = splitter, + seeds = listOf( + "commitment_vault".toByteArray(Charsets.UTF_8), + treasury.bytes.toByteArray(), + commitmentState.bytes.toByteArray() + ) + ) +} + +fun PublicKey.Companion.derivePreSwapState( + source: PublicKey, destination: PublicKey, nonce: PublicKey +): ProgramDerivedAccount { + return findProgramAddress( + programId = SwapValidatorProgram.address, + seeds = listOf( + "pre_swap_state".toByteArray(Charsets.UTF_8), + source.bytes.toByteArray(), + destination.bytes.toByteArray(), + nonce.bytes.toByteArray(), + ) + ) +} \ No newline at end of file diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/model/extensions/SplitterCommitmentAccounts.kt b/services/flipchat/core/src/main/kotlin/com/getcode/model/extensions/SplitterCommitmentAccounts.kt new file mode 100644 index 000000000..3ce695768 --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/model/extensions/SplitterCommitmentAccounts.kt @@ -0,0 +1,64 @@ +package com.getcode.model.extensions + +import com.getcode.model.Kin +import com.getcode.solana.keys.Hash +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.SplitterCommitmentAccounts +import com.getcode.solana.keys.SplitterTranscript +import com.getcode.solana.organizer.AccountCluster + +fun SplitterCommitmentAccounts.Companion.newInstance( + source: AccountCluster, + destination: PublicKey, + amount: Kin, + treasury: PublicKey, + recentRoot: Hash, + intentId: PublicKey, + actionId: Int +): SplitterCommitmentAccounts { + val transcript = SplitterTranscript( + intentId = intentId, + actionId = actionId, + amount = amount, + source = source.vaultPublicKey, + destination = destination + ) + + return newInstance( + treasury = treasury, + destination = destination, + recentRoot = recentRoot, + transcript = transcript.transcriptHash, + amount = amount + ) +} + +fun SplitterCommitmentAccounts.Companion.newInstance( + treasury: PublicKey, + destination: PublicKey, + recentRoot: Hash, + transcript: Hash, + amount: Kin +): SplitterCommitmentAccounts { + val state = PublicKey.deriveCommitmentStateAccount( + treasury = treasury, + recentRoot = recentRoot, + transcript = transcript, + destination = destination, + amount = amount + ) + + val vault = PublicKey.deriveCommitmentVaultAccount( + treasury = treasury, + commitmentState = state.publicKey + ) + + return SplitterCommitmentAccounts( + treasury = treasury, + destination = destination, + recentRoot = recentRoot, + transcript = transcript, + state = state, + vault = vault, + ) +} \ No newline at end of file diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/model/extensions/TimelockDerivedAccounts.kt b/services/flipchat/core/src/main/kotlin/com/getcode/model/extensions/TimelockDerivedAccounts.kt new file mode 100644 index 000000000..08e09159f --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/model/extensions/TimelockDerivedAccounts.kt @@ -0,0 +1,28 @@ +package com.getcode.model.extensions + +import com.getcode.solana.keys.ProgramDerivedAccount +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.TimelockDerivedAccounts + +fun TimelockDerivedAccounts.Companion.newInstance(owner: PublicKey, legacy: Boolean = false): TimelockDerivedAccounts { + val state: ProgramDerivedAccount + val vault: ProgramDerivedAccount + + if (legacy) { + state = + PublicKey.deriveLegacyTimelockStateAccount(owner = owner, lockout = 1_814_400) + vault = PublicKey.deriveLegacyTimelockVaultAccount(stateAccount = state.publicKey) + } else { + state = PublicKey.deriveTimelockStateAccount(owner = owner, lockout = lockoutInDays) + vault = PublicKey.deriveTimelockVaultAccount( + stateAccount = state.publicKey, + version = dataVersion + ) + } + + return TimelockDerivedAccounts( + owner = owner, + state = state, + vault = vault + ) +} \ No newline at end of file diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/model/intents/SwapConfigParameters.kt b/services/flipchat/core/src/main/kotlin/com/getcode/model/intents/SwapConfigParameters.kt new file mode 100644 index 000000000..8a066a1d6 --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/model/intents/SwapConfigParameters.kt @@ -0,0 +1,58 @@ +package com.getcode.model.intents + +import com.codeinc.gen.common.v1.CodeModel.InstructionAccount +import com.codeinc.gen.transaction.v2.CodeTransactionService +import com.getcode.model.toHash +import com.getcode.model.toPublicKey +import com.getcode.solana.keys.AccountMeta +import com.getcode.solana.keys.Hash +import com.getcode.solana.keys.PublicKey +import com.google.protobuf.ByteString + +data class SwapConfigParameters( + val payer: PublicKey, + val swapProgram: PublicKey, + val nonce: PublicKey, + val blockHash: Hash, + val maxToSend: Long, + val minToReceive: Long, + val computeUnitLimit: Int, + val computeUnitPrice: Long, + val swapAccounts: List, + val swapData: ByteString, +) { + companion object { + operator fun invoke(proto: CodeTransactionService.SwapResponse.ServerParameters): SwapConfigParameters? { + return runCatching { + val payer = proto.payer.value.toByteArray().toPublicKey() + val swapProgram = proto.swapProgram.value.toByteArray().toPublicKey() + val nonce = proto.nonce.value.toByteArray().toPublicKey() + val blockHash = proto.recentBlockhash.value.toByteArray().toHash() + + SwapConfigParameters( + payer = payer, + swapProgram = swapProgram, + nonce = nonce, + blockHash = blockHash, + maxToSend = proto.maxToSend, + minToReceive = proto.minToReceive, + computeUnitLimit = proto.computeUnitLimit, + computeUnitPrice = proto.computeUnitPrice, + swapAccounts = proto.swapIxnAccountsList.mapNotNull { it.meta() }, + swapData = proto.swapIxnData + ) + }.getOrNull() + } + } +} + +private fun InstructionAccount.meta(): AccountMeta? = runCatching { + val publicKey = PublicKey(account.value.toList()) + AccountMeta( + publicKey = publicKey, + isSigner = isSigner, + isWritable = isWritable, + isPayer = false, + isProgram = false + ) +}.getOrNull() \ No newline at end of file diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/solana/builder/TransactionBuilder.kt b/services/flipchat/core/src/main/kotlin/com/getcode/solana/builder/TransactionBuilder.kt new file mode 100644 index 000000000..2947b0eaa --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/solana/builder/TransactionBuilder.kt @@ -0,0 +1,223 @@ +package com.getcode.solana.builder + +import com.getcode.solana.keys.Hash +import com.getcode.model.Kin +import com.getcode.model.extensions.newInstance +import com.getcode.model.intents.SwapConfigParameters +import com.getcode.services.model.ExtendedMetadata +import com.getcode.solana.Instruction +import com.getcode.solana.SolanaTransaction +import com.getcode.solana.TransferType +import com.getcode.solana.keys.description +import com.getcode.solana.instructions.programs.* +import com.getcode.solana.keys.Key32.Companion.mock +import com.getcode.solana.keys.Key32.Companion.subsidizer +import com.getcode.solana.keys.PreSwapStateAccount +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.TimelockDerivedAccounts +import com.getcode.solana.organizer.AccountCluster +import com.getcode.vendor.Base58 +import timber.log.Timber + +object TransactionBuilder { + + + fun transfer( + timelockDerivedAccounts: TimelockDerivedAccounts, + destination: PublicKey, + amount: Kin, + nonce: PublicKey, + recentBlockhash: Hash, + kreIndex: Int + ): SolanaTransaction { + return SolanaTransaction.newInstance( + payer = subsidizer, + recentBlockhash = recentBlockhash, + instructions = listOf( + SystemProgram_AdvanceNonce( + nonce = nonce, + authority = subsidizer + ).instruction(), + + MemoProgram_Memo.newInstance( + transferType = TransferType.p2p, + kreIndex = kreIndex + ).instruction(), + + TimelockProgram_TransferWithAuthority( + timelock = timelockDerivedAccounts.state.publicKey, + vault = timelockDerivedAccounts.vault.publicKey, + vaultOwner = timelockDerivedAccounts.owner, + timeAuthority = vmTimeAuthority, + destination = destination, + payer = subsidizer, + bump = timelockDerivedAccounts.state.bump.toByte(), + kin = amount + ).instruction(), + ) + ) + } + + @Deprecated("No longer exists in VM") + fun closeDormantAccount( + authority: PublicKey, + timelockDerivedAccounts: TimelockDerivedAccounts, + destination: PublicKey, + nonce: PublicKey, + recentBlockhash: Hash, + kreIndex: Int, + legacy: Boolean = false, + metadata: ExtendedMetadata? = null, + ): SolanaTransaction { + val instructions = mutableListOf() + + instructions.add(SystemProgram_AdvanceNonce(nonce = nonce, authority = subsidizer).instruction()) + instructions.add( + MemoProgram_Memo.newInstance( + transferType = TransferType.p2p, + kreIndex = kreIndex + ).instruction(), + ) + + when (metadata) { + is ExtendedMetadata.Tip -> { + instructions.add(MemoProgram_Memo.newInstance(metadata.socialUser).instruction()) + } + else -> Unit + } + + instructions.addAll( + listOf( + TimelockProgram_RevokeLockWithAuthority( + timelock = timelockDerivedAccounts.state.publicKey, + vault = timelockDerivedAccounts.vault.publicKey, + closeAuthority = subsidizer, + payer = subsidizer, + bump = timelockDerivedAccounts.state.bump.toByte(), + legacy = legacy + ).instruction(), + + TimelockProgram_DeactivateLock( + timelock = timelockDerivedAccounts.state.publicKey, + vaultOwner = authority, + payer = subsidizer, + bump = timelockDerivedAccounts.state.bump.toByte(), + legacy = legacy + ).instruction(), + + TimelockProgram_Withdraw( + timelock = timelockDerivedAccounts.state.publicKey, + vault = timelockDerivedAccounts.vault.publicKey, + vaultOwner = authority, + destination = destination, + payer = subsidizer, + bump = timelockDerivedAccounts.state.bump.toByte(), + legacy = legacy + ).instruction(), + + TimelockProgram_CloseAccounts( + timelock = timelockDerivedAccounts.state.publicKey, + vault = timelockDerivedAccounts.vault.publicKey, + closeAuthority = subsidizer, + payer = subsidizer, + bump = timelockDerivedAccounts.state.bump.toByte(), + legacy = legacy, + ).instruction() + ), + ) + + return SolanaTransaction.newInstance( + payer = subsidizer, + recentBlockhash = recentBlockhash, + instructions = instructions, + ) + } + + + // Swap performs an on-chain swap. The high-level flow mirrors SubmitIntent + // closely. However, due to the time-sensitive nature and unreliability of + // swaps, they do not fit within the broader intent system. This results in + // a few key differences: + // * Transactions are submitted on a best-effort basis outside of the Code + // Sequencer within the RPC handler + // * Balance changes are applied after the transaction has finalized + // * Transactions use recent blockhashes over a nonce + // + // The transaction will have the following instruction format: + // 1. ComputeBudget::SetComputeUnitLimit + // 2. ComputeBudget::SetComputeUnitPrice + // 3. SwapValidator::PreSwap + // 4. Dynamic swap instruction + // 5. SwapValidator::PostSwap + // + // Note: Currently limited to swapping USDC to Kin. + // Note: Kin is deposited into the primary account. + // + fun swap( + fromUsdc: AccountCluster, + toPrimary: PublicKey, + parameters: SwapConfigParameters + ): SolanaTransaction { + val payer = parameters.payer + val destination = toPrimary + + val stateAccount = PreSwapStateAccount.newInstance( + owner = mock, + source = fromUsdc.vaultPublicKey, + destination = destination, + nonce = parameters.nonce + ) + + Timber.d("swap accounts=${parameters.swapAccounts.map { it.description }}") + val remainingAccounts = parameters.swapAccounts.filter { + (it.isSigner || it.isWritable) && + (it.publicKey != fromUsdc.authorityPublicKey && + it.publicKey != fromUsdc.vaultPublicKey && + it.publicKey != destination) + } + + return SolanaTransaction.newInstance( + payer = payer, + recentBlockhash = parameters.blockHash, + instructions = listOf( + ComputeBudgetProgram_SetComputeUnitLimit( + limit = parameters.computeUnitLimit, + bump = stateAccount.state.bump.toByte(), + ).instruction(), + + ComputeBudgetProgram_SetComputeUnitPrice( + microLamports = parameters.computeUnitPrice, + bump = stateAccount.state.bump.toByte(), + ).instruction(), + + SwapValidatorProgram_PreSwap( + preSwapState = stateAccount.state.publicKey, + user = fromUsdc.authorityPublicKey, + source = fromUsdc.vaultPublicKey, + destination = destination, + nonce = parameters.nonce, + payer = payer, + remainingAccounts = remainingAccounts, + ).instruction(), + + Instruction( + program = parameters.swapProgram, + accounts = parameters.swapAccounts, + data = parameters.swapData.toList(), + ), + + SwapValidatorProgram_PostSwap( + stateBump = stateAccount.state.bump.toByte(), + maxToSend = parameters.maxToSend, + minToReceive = parameters.minToReceive, + preSwapState = stateAccount.state.publicKey, + source = fromUsdc.vaultPublicKey, + destination = destination, + payer = payer, + ).instruction() + ) + ) + } +} + +val vmTimeAuthority = PublicKey(Base58.decode("f1ipC31qd2u88MjNYp1T4Cc7rnWfM9ivYpTV1Z8FHnD").toList()) \ No newline at end of file diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/AccountCluster.kt b/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/AccountCluster.kt new file mode 100644 index 000000000..04cffbb2c --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/AccountCluster.kt @@ -0,0 +1,101 @@ +package com.getcode.solana.organizer + +import com.getcode.crypt.DerivedKey +import com.getcode.crypt.MnemonicPhrase +import com.getcode.model.extensions.newInstance +import com.getcode.model.toPublicKey +import com.getcode.solana.keys.AssociatedTokenAccount +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.TimelockDerivedAccounts + + +class AccountCluster( + val index: Int, + val authority: DerivedKey, + val derivation: Derivation +) { + + val authorityPublicKey: PublicKey + get() = authority.keyPair.publicKeyBytes.toPublicKey() + + val vaultPublicKey: PublicKey + get() = when (derivation) { + is Derivation.Timelock -> timelock!!.vault.publicKey + is Derivation.Usdc -> ata!!.ata.publicKey + } + sealed interface Kind { + data object Timelock: Kind + data object Usdc: Kind + } + + sealed interface Derivation { + data class Timelock(val accounts: TimelockDerivedAccounts): Derivation + data class Usdc(val account: AssociatedTokenAccount): Derivation + } + + val timelock: TimelockDerivedAccounts? + get() = (derivation as? Derivation.Timelock)?.accounts + + val ata: AssociatedTokenAccount? + get() = (derivation as? Derivation.Usdc)?.account + + companion object { + fun newInstanceLazy(authority: DerivedKey, index: Int = 0, kind: Kind, legacy: Boolean = false): Lazy { + return lazy { newInstance(authority, index, kind, legacy) } + } + + fun newInstance(authority: DerivedKey, index: Int = 0, kind: Kind, legacy: Boolean = false): AccountCluster { + return AccountCluster( + index = index, + authority = authority, + derivation = when (kind) { + Kind.Timelock -> Derivation.Timelock( + TimelockDerivedAccounts.newInstance( + owner = PublicKey(authority.keyPair.publicKeyBytes.toList()), + legacy = legacy + ) + ) + Kind.Usdc -> { + Derivation.Usdc( + AssociatedTokenAccount.newInstance( + owner = authority.keyPair.publicKeyBytes.toPublicKey(), + mint = Mint.usdc + ) + ) + } + }, + ) + } + + fun using(type: AccountType, index: Int, mnemonic: MnemonicPhrase): AccountCluster { + return newInstance( + index = index, + authority = DerivedKey.derive( + path = type.getDerivationPath(index), + mnemonic = mnemonic + ), + kind = Kind.Timelock + ) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AccountCluster + + if (authority != other.authority) return false + if (derivation != other.derivation) return false + + return true + } + + override fun hashCode(): Int { + var result = authority.hashCode() + result = 31 * result + derivation.hashCode() + return result + } + +} \ No newline at end of file diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/AccountType.kt b/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/AccountType.kt new file mode 100644 index 000000000..ed38448a8 --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/AccountType.kt @@ -0,0 +1,101 @@ +package com.getcode.solana.organizer + +import com.codeinc.gen.common.v1.CodeModel as Model +import com.getcode.model.Domain + +sealed interface AccountType { + data object Primary : AccountType + data object Incoming : AccountType + data object Outgoing : AccountType + data class Bucket(val type: SlotType) : AccountType + data object RemoteSend: AccountType + + data class Relationship(val domain: Domain): AccountType + + data object Swap: AccountType + + fun sortOrder() = when (this) { + Primary -> 0 + Incoming -> 1 + Outgoing -> 2 + is Bucket -> { + when (type) { + SlotType.Bucket1 -> 3 + SlotType.Bucket10 -> 4 + SlotType.Bucket100 -> 5 + SlotType.Bucket1k -> 6 + SlotType.Bucket10k -> 7 + SlotType.Bucket100k -> 8 + SlotType.Bucket1m -> 9 + } + } + Swap -> 10 + is Relationship -> 11 + RemoteSend -> 12 + } + + fun getDerivationPath(index: Int): com.getcode.crypt.DerivePath { + return when (this) { + Primary -> com.getcode.crypt.DerivePath.primary + Incoming -> com.getcode.crypt.DerivePath.getBucketIncoming(index) + Outgoing -> com.getcode.crypt.DerivePath.getBucketOutgoing(index) + is Bucket -> type.getDerivationPath() + RemoteSend -> { + // Remote send accounts are standard Solana accounts + // and should use a standard derivation path that + // would be compatible with other 3rd party wallets + com.getcode.crypt.DerivePath.primary + } + is Relationship -> com.getcode.crypt.DerivePath.relationship(domain) + Swap -> com.getcode.crypt.DerivePath.swap + } + } + + fun getAccountType(): Model.AccountType { + return when (this) { + Primary -> Model.AccountType.PRIMARY + Incoming -> Model.AccountType.TEMPORARY_INCOMING + Outgoing -> Model.AccountType.TEMPORARY_OUTGOING + is Bucket -> { + when (this.type) { + SlotType.Bucket1 -> Model.AccountType.BUCKET_1_KIN + SlotType.Bucket10 -> Model.AccountType.BUCKET_10_KIN + SlotType.Bucket100 -> Model.AccountType.BUCKET_100_KIN + SlotType.Bucket1k -> Model.AccountType.BUCKET_1_000_KIN + SlotType.Bucket10k -> Model.AccountType.BUCKET_10_000_KIN + SlotType.Bucket100k -> Model.AccountType.BUCKET_100_000_KIN + SlotType.Bucket1m -> Model.AccountType.BUCKET_1_000_000_KIN + } + } + RemoteSend -> Model.AccountType.REMOTE_SEND_GIFT_CARD + is Relationship -> Model.AccountType.RELATIONSHIP + Swap -> Model.AccountType.SWAP + } + } + + companion object { + fun newInstance(accountType: Model.AccountType, relationship: Model.Relationship? = null): AccountType? { + return when (accountType) { + Model.AccountType.PRIMARY -> Primary + Model.AccountType.TEMPORARY_INCOMING -> Incoming + Model.AccountType.TEMPORARY_OUTGOING -> Outgoing + Model.AccountType.BUCKET_1_KIN -> Bucket(SlotType.Bucket1) + Model.AccountType.BUCKET_10_KIN -> Bucket(SlotType.Bucket10) + Model.AccountType.BUCKET_100_KIN -> Bucket(SlotType.Bucket100) + Model.AccountType.BUCKET_1_000_KIN -> Bucket(SlotType.Bucket1k) + Model.AccountType.BUCKET_10_000_KIN -> Bucket(SlotType.Bucket10k) + Model.AccountType.BUCKET_100_000_KIN -> Bucket(SlotType.Bucket100k) + Model.AccountType.BUCKET_1_000_000_KIN -> Bucket(SlotType.Bucket1m) + Model.AccountType.UNKNOWN -> null + Model.AccountType.LEGACY_PRIMARY_2022 -> Primary + Model.AccountType.REMOTE_SEND_GIFT_CARD -> RemoteSend + Model.AccountType.UNRECOGNIZED -> null + Model.AccountType.RELATIONSHIP -> { + val domain = Domain.from(relationship?.domain?.value) ?: return null + Relationship(domain) + } + Model.AccountType.SWAP -> Swap + } + } + } +} \ No newline at end of file diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/GiftCardAccount.kt b/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/GiftCardAccount.kt new file mode 100644 index 000000000..77a307e96 --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/GiftCardAccount.kt @@ -0,0 +1,27 @@ +package com.getcode.solana.organizer + +import com.getcode.crypt.DerivePath +import com.getcode.crypt.DerivedKey +import com.getcode.crypt.MnemonicPhrase + +class GiftCardAccount( + val mnemonicPhrase: MnemonicPhrase, + val cluster: AccountCluster +) { + companion object { + fun newInstance(mnemonicPhrase: MnemonicPhrase? = null): GiftCardAccount { + val phrase = mnemonicPhrase ?: MnemonicPhrase.generate() + + return GiftCardAccount( + mnemonicPhrase = phrase, + cluster = AccountCluster.newInstance( + authority = DerivedKey.derive( + path = DerivePath.primary, + mnemonic = phrase, + ), + kind = AccountCluster.Kind.Timelock, + ) + ) + } + } +} \ No newline at end of file diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/Organizer.kt b/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/Organizer.kt new file mode 100644 index 000000000..4570a61a5 --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/Organizer.kt @@ -0,0 +1,162 @@ +package com.getcode.solana.organizer + +import com.getcode.crypt.MnemonicPhrase +import com.getcode.model.AccountInfo +import com.getcode.model.Domain +import com.getcode.model.Kin +import com.getcode.model.unusable +import com.getcode.solana.keys.* +import com.getcode.utils.TraceType +import com.getcode.utils.getPublicKeyBase58 +import com.getcode.utils.trace +import timber.log.Timber + +class Organizer( + val tray: Tray, + val mnemonic: MnemonicPhrase, + private var accountInfos: Map = mapOf(), +) { + val slotsBalance get() = tray.slotsBalance + val availableBalance get() = tray.availableBalance + val availableDepositBalance get() = tray.owner.partialBalance + val availableIncomingBalance get() = tray.incoming.partialBalance + val availableRelationshipBalance get() = tray.availableRelationshipBalance + val ownerKeyPair get() = tray.owner.getCluster().authority.keyPair + val swapKeyPair get() = tray.swap.getCluster().authority.keyPair + val swapDepositAddress get() = swapKeyPair.getPublicKeyBase58() + val primaryVault get() = tray.owner.getCluster().vaultPublicKey + val incomingVault get() = tray.incoming.getCluster().vaultPublicKey + + val isUnuseable: Boolean get() = accountInfos.any { it.value.unusable } + + val createdAtMillis: Long? get() = accountInfos.mapNotNull { it.value.createdAt }.minOrNull() + + val isUnlocked: Boolean + get() = accountInfos.values.any { info -> + info.managementState != AccountInfo.ManagementState.Locked + } + + fun set(tray: Tray) { + this.tray.slots = tray.slots + this.tray.owner = tray.owner + this.tray.incoming = tray.incoming + this.tray.outgoing = tray.outgoing + this.tray.mnemonic = tray.mnemonic + } + + fun setBalances(balances: Map) { + tray.setBalances(balances) + } + + fun allAccounts() = tray.allAccounts() + + fun info(accountType: AccountType): AccountInfo? { + val account = tray.cluster(accountType).vaultPublicKey + return accountInfos[account] + } + + fun setAccountInfo(infos: Map) { + this.accountInfos = infos + tray.createRelationships(infos) + propagateBalances() + + trace( + tag = "Organizer", + message = "Fetched account infos", + type = TraceType.Process, + metadata = { + "tray" to tray.reportableRepresentation() + } + ) + } + + fun getAccountInfo() = accountInfos + + val buckets: List + get() = accountInfos.values.toList().sortedBy { it.accountType.sortOrder() } + + fun propagateBalances() { + val balances = mutableMapOf() + com.getcode.utils.timedTrace("propagate balances") { + for ((vaultPublicKey, info) in accountInfos) { + if (tray.publicKey(info.accountType) == vaultPublicKey) { + balances[info.accountType] = info.balance + } else { + // The public key above doesn't match any accounts + // that the Tray is aware of. If we're dealing with + // temp I/O accounts then we likely just need to + // update the index and try again + when (info.accountType) { + AccountType.Incoming, AccountType.Outgoing -> { + // Update the index + tray.setIndex(info.index, accountType = info.accountType) + Timber.i("Updating ${info.accountType} index to: ${info.index}") + + // Ensure that the account matches + if (tray.publicKey(info.accountType) != vaultPublicKey) { + Timber.i("Indexed account mismatch. This isn't suppose to happen.") + continue + } + balances[info.accountType] = info.balance + } + + AccountType.Primary, + is AccountType.Bucket, + AccountType.RemoteSend, + is AccountType.Relationship, + AccountType.Swap -> { + Timber.i("Non-indexed account mismatch. Account doesn't match server-provided account. Something is definitely wrong") + } + } + } + } + } + + setBalances(balances) + } + + fun relationshipFor(domain: Domain): Relationship? { + return tray.relationships.relationshipWith(domain) + } + + fun relationshipsLargestFirst(): List { + return tray.relationships.relationships(largestFirst = true).also { + Timber.d("relationships=${it.joinToString { it.domain.urlString }}") + } + } + + companion object { + fun newInstance( + mnemonic: MnemonicPhrase + ): Organizer { + val tray = Tray.newInstance(mnemonic) + return Organizer( + mnemonic = mnemonic, + tray = tray, + ) + } + } +} + +enum class Denomination { + ones, + tens, + hundreds, + thousands, + tenThousands, + hundredThousands, + millions; + + val derivationPath: com.getcode.crypt.DerivePath + get() { + return when (this) { + ones -> com.getcode.crypt.DerivePath.bucket1 + tens -> com.getcode.crypt.DerivePath.bucket10 + hundreds -> com.getcode.crypt.DerivePath.bucket100 + thousands -> com.getcode.crypt.DerivePath.bucket1k + tenThousands -> com.getcode.crypt.DerivePath.bucket10k + hundredThousands -> com.getcode.crypt.DerivePath.bucket100k + millions -> com.getcode.crypt.DerivePath.bucket1m + } + } +} diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/PartialAccount.kt b/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/PartialAccount.kt new file mode 100644 index 000000000..4922ba973 --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/PartialAccount.kt @@ -0,0 +1,7 @@ +package com.getcode.solana.organizer + +import com.getcode.model.Kin + +data class PartialAccount(val cluster: Lazy, var partialBalance: Kin = Kin.fromKin(0)) { + fun getCluster() = cluster.value +} \ No newline at end of file diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/Relationship.kt b/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/Relationship.kt new file mode 100644 index 000000000..11ac4cf27 --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/Relationship.kt @@ -0,0 +1,41 @@ +package com.getcode.solana.organizer + +import com.getcode.crypt.DerivePath +import com.getcode.crypt.DerivedKey +import com.getcode.crypt.MnemonicPhrase +import com.getcode.model.Domain +import com.getcode.model.Kin + +class Relationship( + val domain: Domain, + val mnemonic: MnemonicPhrase, + var partialBalance: Kin = Kin.fromKin(0), +) { + private lateinit var cluster: Lazy + + fun getCluster() = cluster.value + + companion object { + fun newInstance( + domain: Domain, + mnemonic: MnemonicPhrase, + partialKinBalance: Kin = Kin.fromKin(0), + ): Relationship { + val cluster = AccountCluster.newInstanceLazy( + DerivedKey.derive( + path = DerivePath.relationship(domain), + mnemonic = mnemonic + ), + kind = AccountCluster.Kind.Timelock, + ) + + return Relationship( + domain = domain, + mnemonic = mnemonic, + partialBalance = partialKinBalance + ).apply { + this.cluster = cluster + } + } + } +} \ No newline at end of file diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/Slot.kt b/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/Slot.kt new file mode 100644 index 000000000..712c01329 --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/Slot.kt @@ -0,0 +1,77 @@ +package com.getcode.solana.organizer + +import com.getcode.crypt.DerivePath +import com.getcode.crypt.DerivedKey +import com.getcode.crypt.MnemonicPhrase +import com.getcode.model.Kin + + +data class Slot( + var partialBalance: Kin, + val type: SlotType, + private val cluster: Lazy +) { + val billValue: Int = type.getBillValue() + + fun billCount(): Long { + return (partialBalance.toKinValueDouble() / type.getBillValue()).toLong() + } + + fun getCluster() = cluster.value + + companion object { + fun newInstance( + partialBalance: Kin = Kin.fromQuarks(0), + type: SlotType, + mnemonic: MnemonicPhrase + ): Slot { + return Slot( + partialBalance = partialBalance, + type = type, + cluster = lazy { + AccountCluster.newInstance( + DerivedKey.derive( + type.getDerivationPath(), + mnemonic + ), + kind = AccountCluster.Kind.Timelock, + ) + } + ) + } + } +} + +enum class SlotType { + Bucket1, + Bucket10, + Bucket100, + Bucket1k, + Bucket10k, + Bucket100k, + Bucket1m; +} + +fun SlotType.getBillValue(): Int = + when (this.ordinal) { + SlotType.Bucket1.ordinal -> 1 + SlotType.Bucket10.ordinal -> 10 + SlotType.Bucket100.ordinal -> 100 + SlotType.Bucket1k.ordinal -> 1_000 + SlotType.Bucket10k.ordinal -> 10_000 + SlotType.Bucket100k.ordinal -> 100_000 + SlotType.Bucket1m.ordinal -> 1_000_000 + else -> throw IllegalStateException() + } + +fun SlotType.getDerivationPath(): DerivePath = + when (this.ordinal) { + SlotType.Bucket1.ordinal -> DerivePath.bucket1 + SlotType.Bucket10.ordinal -> DerivePath.bucket10 + SlotType.Bucket100.ordinal -> DerivePath.bucket100 + SlotType.Bucket1k.ordinal -> DerivePath.bucket1k + SlotType.Bucket10k.ordinal -> DerivePath.bucket10k + SlotType.Bucket100k.ordinal -> DerivePath.bucket100k + SlotType.Bucket1m.ordinal -> DerivePath.bucket1m + else -> null + }.let { it ?: throw IllegalStateException() } diff --git a/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/Tray.kt b/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/Tray.kt new file mode 100644 index 000000000..e3b59e37d --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/com/getcode/solana/organizer/Tray.kt @@ -0,0 +1,957 @@ +package com.getcode.solana.organizer + +import com.getcode.crypt.DerivePath +import com.getcode.crypt.DerivedKey +import com.getcode.crypt.MnemonicPhrase +import com.getcode.model.AccountInfo +import com.getcode.model.Domain +import com.getcode.model.Kin +import com.getcode.model.RelationshipBox +import com.getcode.model.description +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.base58 +import com.getcode.utils.TraceType +import com.getcode.services.utils.padded +import com.getcode.utils.trace +import kotlin.math.min + +class Tray( + var slots: List, + var owner: PartialAccount, + var swap: PartialAccount, + var incoming: PartialAccount, + var outgoing: PartialAccount, + var mnemonic: MnemonicPhrase +) { + var slotsBalance: Kin = Kin.fromKin(0) + get() = slots.map { it.partialBalance }.reduce { acc, slot -> acc + slot } + private set + + var availableBalance: Kin = Kin.fromKin(0) + get() = slotsBalance + availableDepositBalance + availableIncomingBalance + availableRelationshipBalance + private set + + private val availableDepositBalance: Kin + get() = owner.partialBalance + + private val availableIncomingBalance: Kin + get() = incoming.partialBalance + + var relationships = RelationshipBox() + internal set + + var availableRelationshipBalance: Kin = Kin.fromKin(0) + get() = relationships.publicKeys.values.map { it.partialBalance } + .reduceOrNull { acc, slot -> acc + slot } ?: Kin.fromKin(0) + private set + + fun slot(type: SlotType): Slot { + return slots.first { it.type == type } + } + + fun slotDown(type: SlotType): Slot? { + val index = slots.indexOfFirst { it.type == type } + if (index > 0) { + return slots[index - 1] + } + return null + } + + fun slotUp(type: SlotType): Slot? { + val index = slots.indexOfFirst { it.type == type } + if (index < slots.size - 1) { + return slots[index + 1] + } + return null + } + + fun increment(type: AccountType, kin: Kin) { + when (type) { + AccountType.Primary -> owner.partialBalance += kin + AccountType.Incoming -> incoming.partialBalance += kin + AccountType.Outgoing -> outgoing.partialBalance += kin + is AccountType.Bucket -> slots[type.type.ordinal].partialBalance += kin + AccountType.RemoteSend -> throw IllegalStateException("Remote send account unsupported") + is AccountType.Relationship -> { + val relationship = relationships.relationshipWith(type.domain) + ?: throw IllegalStateException("Relationship for ${type.domain.relationshipHost}) not found in ${relationships.domains}") + relationships.insert( + relationship.apply { + partialBalance += kin + } + ) + } + + AccountType.Swap -> swap.partialBalance += kin + } + } + + fun decrement(type: AccountType, kin: Kin) { + when (type) { + AccountType.Primary -> owner.partialBalance -= kin + AccountType.Incoming -> incoming.partialBalance -= kin + AccountType.Outgoing -> outgoing.partialBalance -= kin + is AccountType.Bucket -> slots[type.type.ordinal].partialBalance -= kin + AccountType.RemoteSend -> throw IllegalStateException("Remote send account unsupported") + is AccountType.Relationship -> { + val relationship = relationships.relationshipWith(type.domain) + ?: throw IllegalStateException("Relationship for ${type.domain.relationshipHost}) not found in ${relationships.domains}") + relationships.insert( + relationship.apply { + partialBalance -= kin + } + ) + } + AccountType.Swap -> swap.partialBalance -= kin + } + } + + fun setBalances(balances: Map) { + owner.partialBalance = balances[AccountType.Primary] ?: owner.partialBalance + incoming.partialBalance = balances[AccountType.Incoming] ?: incoming.partialBalance + outgoing.partialBalance = balances[AccountType.Outgoing] ?: outgoing.partialBalance + + slots[0].partialBalance = balances[AccountType.Bucket(SlotType.Bucket1)] ?: slots[0].partialBalance + slots[1].partialBalance = balances[AccountType.Bucket(SlotType.Bucket10)] ?: slots[1].partialBalance + slots[2].partialBalance = balances[AccountType.Bucket(SlotType.Bucket100)] ?: slots[2].partialBalance + slots[3].partialBalance = balances[AccountType.Bucket(SlotType.Bucket1k)] ?: slots[3].partialBalance + slots[4].partialBalance = balances[AccountType.Bucket(SlotType.Bucket10k)] ?: slots[4].partialBalance + slots[5].partialBalance = balances[AccountType.Bucket(SlotType.Bucket100k)] ?: slots[5].partialBalance + slots[6].partialBalance = balances[AccountType.Bucket(SlotType.Bucket1m)] ?: slots[6].partialBalance + + balances.filter { (type, _) -> type is AccountType.Relationship } + .mapNotNull { (type, amount) -> + val relationshipType = type as? AccountType.Relationship ?: return@mapNotNull null + relationshipType to amount + } + .onEach { (relationship, amount) -> + val domain = relationship.domain + setBalance(domain, amount) + } + } + + private fun setBalance(domain: Domain, balance: Kin) { + val relationship = relationships.relationshipWith(domain) ?: return + relationships.insert(relationship.apply { + partialBalance = balance + }) + } + + fun partialBalance(type: AccountType): Kin { + return when (type) { + is AccountType.Primary -> owner.partialBalance + is AccountType.Incoming -> incoming.partialBalance + is AccountType.Outgoing -> outgoing.partialBalance + is AccountType.Bucket -> slot(type.type).partialBalance + AccountType.RemoteSend -> throw IllegalStateException("Remote send account unsupported") + is AccountType.Relationship -> { + val relationship = relationships.relationshipWith(type.domain) + ?: throw IllegalStateException("Relationship for ${type.domain.relationshipHost}) not found in ${relationships.domains}") + + return relationship.partialBalance + } + + AccountType.Swap -> swap.partialBalance + } + } + + fun createRelationships(accountInfos: Map) { + val domains= accountInfos + .mapNotNull { it.value.relationship?.domain } + + domains.onEach { createRelationship(it) } + } + + fun createRelationship(domain: Domain): Relationship { + val relationship = Relationship.newInstance(domain, mnemonic) + relationships.insert(relationship) + return relationship + } + + fun incrementIncoming() { + setIndex(incoming.getCluster().index + 1, AccountType.Incoming) + } + + fun incrementOutgoing() { + setIndex(outgoing.getCluster().index + 1, AccountType.Outgoing) + } + + fun setIndex(index: Int, accountType: AccountType) { + when (accountType) { + AccountType.Incoming -> { + incoming = PartialAccount(cluster = incoming(index, mnemonic)) + } + AccountType.Outgoing -> { + outgoing = PartialAccount(cluster = outgoing(index, mnemonic)) + } + + is AccountType.Bucket, + AccountType.Primary, + is AccountType.Relationship, + AccountType.RemoteSend, + AccountType.Swap -> { + throw IllegalStateException() + } + } + } + + fun allAccounts(): List> { + return listOf( + Pair(AccountType.Primary, owner.getCluster()), + Pair(AccountType.Incoming, incoming.getCluster()), + Pair(AccountType.Outgoing, outgoing.getCluster()), + *slots.map { Pair(AccountType.Bucket(it.type), it.getCluster()) }.toTypedArray(), + ) + } + + fun publicKey(accountType: AccountType): PublicKey { + return cluster(accountType).vaultPublicKey + } + + fun cluster(accountType: AccountType): AccountCluster { + return when (accountType) { + AccountType.Primary -> owner.getCluster() + AccountType.Incoming -> incoming.getCluster() + AccountType.Outgoing -> outgoing.getCluster() + is AccountType.Bucket -> slot(accountType.type).getCluster() + AccountType.RemoteSend -> throw IllegalStateException("Remote send account unsupported") + is AccountType.Relationship -> { + relationships.relationshipWith(domain = accountType.domain)!!.getCluster() + } + + AccountType.Swap -> swap.getCluster() + } + } + + fun copy(): Tray { + return Tray( + slots = slots.map { it.copy() }, + owner = owner.copy(), + swap = swap.copy(), + incoming = incoming.copy(), + outgoing = outgoing.copy(), + mnemonic = mnemonic, + ).apply tray@{ + this@tray.relationships = this@Tray.relationships + } + } + + companion object { + fun newInstance( + mnemonic: MnemonicPhrase + ): Tray { + return Tray( + mnemonic = mnemonic, + slots = listOf( + Slot.newInstance( + type = SlotType.Bucket1, + mnemonic = mnemonic + ), + Slot.newInstance( + type = SlotType.Bucket10, + mnemonic = mnemonic + ), + Slot.newInstance( + type = SlotType.Bucket100, + mnemonic = mnemonic + ), + Slot.newInstance( + type = SlotType.Bucket1k, + mnemonic = mnemonic + ), + Slot.newInstance( + type = SlotType.Bucket10k, + mnemonic = mnemonic + ), + Slot.newInstance( + type = SlotType.Bucket100k, + mnemonic = mnemonic + ), + Slot.newInstance( + type = SlotType.Bucket1m, + mnemonic = mnemonic + ), + ), + incoming = PartialAccount(incoming(0, mnemonic)), + outgoing = PartialAccount(outgoing( 0, mnemonic)), + owner = PartialAccount( + cluster = AccountCluster.newInstanceLazy( + authority = DerivedKey.derive(DerivePath.primary, mnemonic), + kind = AccountCluster.Kind.Timelock, + ) + ), + swap = PartialAccount( + cluster = AccountCluster.newInstanceLazy( + authority = DerivedKey.derive(DerivePath.swap, mnemonic), + kind = AccountCluster.Kind.Usdc, + ) + ) + ) + } + + fun incoming(index: Int, mnemonic: MnemonicPhrase): Lazy { + return lazy { + AccountCluster.newInstance( + authority = DerivedKey.derive( + DerivePath.getBucketIncoming(index), + mnemonic + ), + index = index, + kind = AccountCluster.Kind.Timelock, + ) + } + } + + fun outgoing(index: Int, mnemonic: MnemonicPhrase): Lazy { + return lazy { + AccountCluster.newInstance( + authority = DerivedKey.derive( + DerivePath.getBucketOutgoing(index), + mnemonic + ), + index = index, + kind = AccountCluster.Kind.Timelock, + ) + } + } + } + + + // MARK: - Redistribute - + + /// Redistribute the bills in the organizer to ensure there are no gaps + /// in consecutive slots. + /// + /// For example, avoid this: + /// ---------------------------------------------------------------- + /// | slot 0 | slot 1 | slot 2 | slot 3 | slot 4 | slot 5 | slot 6 | + /// ---------------------------------------------------------------- + /// | 1 | 0 | 10 | 10 | 0 | 0 | 0 | = 1,101 + /// ^---------^--- not optimal + /// + /// Instead, we want this: + /// ---------------------------------------------------------------- + /// | slot 0 | slot 1 | slot 2 | slot 3 | slot 4 | slot 5 | slot 6 | + /// ---------------------------------------------------------------- + /// | 11 | 9 | 9 | 10 | 0 | 0 | 0 | = 1,101 + /// ^---------^--------┘ split the 10 downwards + /// + /// The examples above both have the same total balance, but the second + /// example should allow for more efficient payments later down the line. + /// + /// We also try to limit the number of bills in each slot as a secondary + /// goal. This is done by recursively exchanging large bills for smaller + /// bills and vice versa with rules around how many of each denomination + /// to keep. Typically, you never need more than 9 pennies to make any + /// payment. + /// + /// Algorithm: + /// -------------------------------------------------------------------- + /// 1) First we take large bills and exchange them for smaller bills one + /// at a time. We do this recursively until we can't exchange any more + /// large bills to small ones. This spreads out our total balance over + /// as many slots as possible. + /// + /// 2) Then we take smaller bills and exchange them for larger bills if + /// we have more than needed in any slot. This reduces the number of + /// bills we have in total. + /// + /// This algorithm guarantees that we will never have gaps (zero balance) + /// between consecutive slots (e.g. 1000, 0, 10, 1). + /// --------------------------------------------------------------------- + /// + /// TODO: this algorithm could be optimized to reduce the number of + /// transactions + fun redistribute(): List { + val exchanges = mutableListOf() + + exchanges.addAll( + exchangeLargeToSmall() + ) + + exchanges.addAll( + exchangeSmallToLarge() + ) + + return exchanges + } + + fun receive(receivingAccount: AccountType, amount: Kin): List { + if (partialBalance(receivingAccount) < amount) throw OrganizerException.InvalidSlotBalanceException() + + val container = mutableListOf() + + var remainingAmount = amount + + for (i in (slots.size - 1 downTo 0)) { + val currentSlot = slots[i] + + val howManyFit: Int = (remainingAmount.toKinValueDouble() / currentSlot.billValue).toInt() + if (howManyFit > 0) { + val amountToDeposit = Kin.fromKin(howManyFit * currentSlot.billValue) + + normalize(slotType = currentSlot.type, amount = amountToDeposit) { subAmount -> + container.add( + InternalExchange( + from = receivingAccount, + to = AccountType.Bucket(currentSlot.type), + kin = subAmount + ) + ) + } + + decrement(receivingAccount, amountToDeposit) + increment(AccountType.Bucket(currentSlot.type), amountToDeposit) + + remainingAmount -= amountToDeposit + } + } + + return container + } + + /// Recursive function to exchange large bills to smaller bills (when + /// possible). For example, if we have dimes but no pennies, we should + /// break a dime into pennies. + /// + + fun exchangeLargeToSmall(layer: Int = 0): List { + val padding = "-".repeat(layer + 1) + "|" + + val exchanges = mutableListOf() + + for (i in 1..slots.size) { + val currentSlot = slots[slots.size - i] // Backwards + val smallerSlot = slotDown(currentSlot.type) + + trace( + "$padding o Checking slot: ${currentSlot.type}", + type = TraceType.Silent + ) + + if (smallerSlot == null) { + // We're at the lowest denomination + // so we can't exchange anymore. + trace( + "$padding x Last slot", + type = TraceType.Silent + ) + break + } + + if (currentSlot.billCount() <= 0) { + // Nothing to exchange, the current slot is empty. + trace( + "$padding x Empty", + type = TraceType.Silent + ) + continue + } + + val howManyFit = currentSlot.billValue / smallerSlot.billValue + + if (smallerSlot.billCount() >= howManyFit - 1) { + // No reason to exchange yet, the smaller slot + // already has enough bills for most payments + trace( + "$padding x Enough bills", + type = TraceType.Silent + ) + continue + } + + val amount = Kin.fromKin(currentSlot.billValue) + + // Adjust the slot balance + decrement(AccountType.Bucket(currentSlot.type), kin = amount) + increment(AccountType.Bucket(smallerSlot.type), kin = amount) + + trace( + message = "$padding v Exchanging from ${currentSlot.type} to ${smallerSlot.type} $amount Kin", + type = TraceType.Silent + ) + + exchanges.add( + InternalExchange( + from = AccountType.Bucket(currentSlot.type), + to = AccountType.Bucket(smallerSlot.type), + kin = amount + ) + ) + + exchanges.addAll( + exchangeLargeToSmall(layer = layer + 1) + ) // Recursive + } + + return exchanges + } + + /// Recursive function to exchange small bills to larger bills (when + /// possible). + /// + /// For example, if we have 19 pennies or more, we should exchange excess + /// pennies for dimes. But if we only have 18 pennies or less, we + /// should not exchange any because we'd be unable to make a future + /// payment that has a $0.09 amount (there are some edge cases). + /// + + fun exchangeSmallToLarge(layer: Int = 0): List { + val padding = "-".repeat(layer + 1) + "|" + + val exchanges = mutableListOf() + + for (element in slots) { + + val currentSlot = element // Forwards + val largerSlot = slotUp(currentSlot.type) + + trace("$padding o Checking slot: ${currentSlot.type}") + + if (largerSlot == null) { + // We're at the largest denomination + // so we can't exchange anymore. + trace( + "$padding x Last slot", + type = TraceType.Silent + ) + break + } + + // First we need to check how many bills of the current type fit + // into the next slot. + + val howManyFit = largerSlot.billValue / currentSlot.billValue + val howManyWeHave = currentSlot.billCount() + val howManyToLeave = min(howManyFit - 1L, howManyWeHave) + + if (howManyWeHave < ((howManyFit * 2) - 1)) { + // We don't have enough bills to exchange, so we can't do + // anything in this slot at the moment. + trace( + "$padding x Not enough bills", + type = TraceType.Silent + ) + continue + } + + val howManyToExchange = (howManyWeHave - howManyToLeave) / howManyFit * howManyFit + val amount = Kin.fromKin(kin = howManyToExchange) * currentSlot.billValue + + val slotTransfers = mutableListOf() + + normalizeLargest(amount = amount) { partialAmount -> + slotTransfers.add( + InternalExchange( + from = AccountType.Bucket(currentSlot.type), + to = AccountType.Bucket(largerSlot.type), + kin = partialAmount + ) + ) + } + + // Adjust the slot balance + decrement(AccountType.Bucket(currentSlot.type), kin = amount) + increment(AccountType.Bucket(largerSlot.type), kin = amount) + + slotTransfers.forEach { transfer -> + trace( + message = "$padding v Exchanging from ${transfer.from} to {transfer.to!} {transfer.kin} Kin", + type = TraceType.Silent + ) + } + + exchanges.addAll( + slotTransfers + ) + + exchanges.addAll( + exchangeSmallToLarge(layer = layer + 1) + ) // Recursive + } + + return exchanges + } + + fun normalize(slotType: SlotType, amount: Kin, handler: (Kin) -> Unit) { + var howManyFit = amount.toKinTruncatingLong() / slotType.getBillValue() + while (howManyFit > 0) { + val billsToMove = min(howManyFit, 9) + val moveAmount = Kin.fromKin(slotType.getBillValue() * billsToMove) + + handler(moveAmount) + + howManyFit -= billsToMove + } + } + + fun normalizeLargest(amount: Kin, handler: (Kin) -> Unit) { + var remainingAmount = amount + + // Starting from largest denomination to the smallest + // we'll find how many 'bills' from each stack we need + for (i in 1..slots.size) { + val slot = slots[slots.size - i] // Backwards + + var howManyFit = remainingAmount.toKinTruncatingLong() / slot.billValue + while (howManyFit > 0) { + val billsToMove = min(howManyFit, 9) + val moveAmount = Kin.fromKin(kin = slot.billValue * billsToMove) + + handler(moveAmount) + + remainingAmount -= moveAmount + howManyFit -= billsToMove + } + } + } + + + /// This function sends money from the organizer to the outgoing + /// temporary account. It has to solve the interesting problem of + /// figuring out which denominations to use when making a payment. + /// + /// Unfortunately, this is actually a pretty hard + /// problem to solve optimally. + /// https://en.wikipedia.org/wiki/Change-making_problem + /// + /// We're going to use the following approach, which should be pretty + /// good most of the time but definitely has room for improvement. + /// Specifically, we may want to move from a dynamic programming + /// solution to a greedy solution in the future. + /// + /// Algorithm + /// + /// 1. Check the total balance to make sure we have enough to send. + /// + /// 2. Try using a naive approach where we send from the amounts + /// currently in the slots. This will fail if we don't have enough of + /// a particular bill to pay the amount. + /// + /// 3. If step 2 fails, start at the smallest denomination and move + /// upwards while adding everything along the way until we reach a + /// denomination that is larger than the remaining amount. Then split + /// and go backwards... (dynamic programming strategy) + /// + fun transfer(amount: Kin): List { + if (amount <= 0) { + throw OrganizerException.InvalidAmountException() + } + + if (amount > availableBalance) { + throw OrganizerException.InsufficientTrayBalanceException() + } + + val startState = this.copy() + + return try { + withdrawNaively(amount = amount) + } catch (e: OrganizerException) { + this.slots = startState.slots.map { it.copy() } + this.owner = startState.owner.copy() + this.incoming = startState.incoming.copy() + this.outgoing = startState.outgoing.copy() + withdrawDynamically(amount = amount) + } + } + + fun withdrawNaively(amount: Kin): List { + if (amount <= 0) { + throw OrganizerException.InvalidAmountException() + } + + val container = mutableListOf() + + var remainingAmount = amount + + // Starting from largest denomination to the smallest + // we'll find how many 'bills' from each stack we need + for (i in 1..slots.size) { + val slot = slots[slots.size - i] // Backwards + + if (slot.partialBalance <= 0) { + continue + } + + val howManyFit = remainingAmount.toKinTruncatingLong() / slot.billValue + + val maxAmount = Kin.fromKin(howManyFit * slot.billValue) + val howMuchToSend: Kin = if (slot.partialBalance < maxAmount) slot.partialBalance else maxAmount + + if (howMuchToSend > 0) { + if (slot.partialBalance < howMuchToSend) { + throw OrganizerException.InvalidSlotBalanceException() + } + + val sourceBucket = AccountType.Bucket(slot.type) + + normalize(slotType = slot.type, amount = howMuchToSend) { amountN -> + container.add( + InternalExchange( + from = sourceBucket, + to = AccountType.Outgoing, + kin = amountN + ) + ) + } + + decrement(sourceBucket, kin = howMuchToSend) + increment(AccountType.Outgoing, kin = howMuchToSend) + + remainingAmount -= howMuchToSend + } + } + + if (remainingAmount >= 1) { + throw OrganizerException.InvalidSlotBalanceException() + } + + return container + } + + fun withdrawDynamically(amount: Kin): List { + if (amount <= 0) { + throw OrganizerException.InvalidAmountException() + } + + if (amount > availableBalance) { + throw OrganizerException.InsufficientTrayBalanceException() + } + + val step = withdrawDynamicallyStep1(amount = amount) + val exchanges = withdrawDynamicallyStep2(step = step) + + return step.exchanges + exchanges + } + + /// This function assumes that the 'naive strategy' withdrawal was already + /// attempted. We'll iterate over the slots, from smallest to largest, drain + /// every slot up to the `amount`. Once a slot that is larger than the + /// remaining amount is reached, the function returns the index at which the + /// second step should resume. + /// + /// Returns the index that should be broken down in step 2. + /// + fun withdrawDynamicallyStep1(amount: Kin): InternalDynamicStep { + val container = mutableListOf() + var remaining = amount + + for (i in slots.indices) { + val currentSlot = slots[i] // Forwards + + if (currentSlot.partialBalance <= 0) { + // Try next slot + continue + } + + if (remaining.toKinValueDouble() < 1) { + // Sent it all + break + } + + if (remaining.toKinTruncatingLong() < currentSlot.billValue) { + // If there's a remaining amount and the current + // bill value is greater, we'll need to break the + // current slot bill down to lower slots + break + } + + val howManyFit = remaining.toKinTruncatingLong() / currentSlot.billValue + val maxAmount = howManyFit * currentSlot.billValue + val howMuchToSend = + min(currentSlot.partialBalance.toKinValueDouble(), maxAmount.toDouble()) + .let { Kin.fromKin(it) } + + if (howMuchToSend > 0) { + normalize(slotType = currentSlot.type, amount = howMuchToSend) { kinToSend -> + container.add( + InternalExchange( + from = AccountType.Bucket(currentSlot.type), + to = AccountType.Outgoing, + kin = kinToSend + ) + ) + } + + // Adjust the slot balance + decrement(AccountType.Bucket(currentSlot.type), kin = howMuchToSend) + increment(AccountType.Outgoing, kin = howMuchToSend) + + remaining -= howMuchToSend + } + } + + var index = slots.indexOfFirst { it.billValue > remaining.toKinTruncatingLong() && it.billCount() > 0 } + + // Only throw an error if there's a + // non-zero remaining amount, other + // wise the first step covered the + // total amount + if (index == -1 && remaining >= 1) { + throw OrganizerException.InvalidStepIndexException() + } + + if (index == -1) index = 0 + return InternalDynamicStep( + remaining = remaining, + index = index, + exchanges = container + ) + } + + fun withdrawDynamicallyStep2(step: InternalDynamicStep): List { + if (!(step.index > 0 && step.index < slots.size)) { + return listOf() + } + + if (step.remaining < 1) { + return listOf() + } + + val container = mutableListOf() + var remaining = step.remaining + + val current = slots[step.index] + val lower = slots[step.index - 1] + + if (current.billCount() < 1) { + throw OrganizerException.SlotAtIndexEmptyException() + } + + // Break the current slot into the lower + // slot and exchange all the way down + val initialSplitAmount = Kin.fromKin(kin = current.billValue) + container.add( + InternalExchange( + from = AccountType.Bucket(current.type), + to = AccountType.Bucket(lower.type), + kin = initialSplitAmount + ) + ) + + // Adjust the slot balance + decrement(type = AccountType.Bucket(current.type), kin = initialSplitAmount) + increment(type = AccountType.Bucket(lower.type), kin = initialSplitAmount) + + + for (i in (step.index-1 downTo 0)) { + val currentSlot = slots[i] + + // Split every slot down to the smallest + // to ensure we have enough bills in each + if (i > 0) { + val lowerSlot = slots[i - 1] + val splitAmount = Kin.fromKin(currentSlot.billValue) + + container.add( + InternalExchange( + from = AccountType.Bucket(currentSlot.type), + to = AccountType.Bucket(lowerSlot.type), + kin = splitAmount + ) + ) + + // Adjust the slot balance + decrement(AccountType.Bucket(currentSlot.type), splitAmount) + increment(AccountType.Bucket(lowerSlot.type), splitAmount) + } + + val howManyFit = remaining.toKinTruncatingLong() / currentSlot.billValue + val kinToSend = Kin.fromKin(howManyFit * currentSlot.billValue) + + if (howManyFit <= 0) { + continue + } + + if (howManyFit > currentSlot.billCount().toInt()) { + throw OrganizerException.InvalidSlotBalanceException() + } + + container.add( + InternalExchange( + from = AccountType.Bucket(currentSlot.type), + to = AccountType.Outgoing, + kin = kinToSend + ) + ) + + // Adjust the slot balance + decrement(AccountType.Bucket(currentSlot.type), kin = kinToSend) + increment(AccountType.Outgoing, kin = kinToSend) + + remaining -= kinToSend + } + + return container + } + + fun reportableRepresentation(): List { + return listOf( + string(named = "Primary ", partialAccount = owner), + string(named = "Incoming ", partialAccount = incoming), + string(named = "Outgoing ", partialAccount = outgoing), + string("1 ", slot = slot(SlotType.Bucket1)), + string("10 ", slot = slot(SlotType.Bucket10)), + string("100 ", slot = slot(SlotType.Bucket100)), + string("1k ", slot = slot(SlotType.Bucket1k)), + string("10k ", slot = slot(SlotType.Bucket10k)), + string("100k ", slot = slot(SlotType.Bucket100k)), + string("1m ", slot = slot(SlotType.Bucket1m)), + ) + } + + private fun string(named: String, partialAccount: PartialAccount): String { + return "$named ${partialAccount.getCluster().vaultPublicKey.base58().padded(44)}) ${partialAccount.partialBalance.description}" + } + + private fun string(named: String, slot: Slot): String { + return "$named ${slot.getCluster().vaultPublicKey.base58().padded(44)}) ${slot.partialBalance.description}" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Tray + + if (slots != other.slots) return false + if (incoming != other.incoming) return false + if (outgoing != other.outgoing) return false + if (mnemonic != other.mnemonic) return false + + return true + } + + override fun hashCode(): Int { + var result = slots.hashCode() + result = 31 * result + incoming.hashCode() + result = 31 * result + outgoing.hashCode() + result = 31 * result + mnemonic.hashCode() + return result + } + + sealed class OrganizerException : Exception() { + class InvalidAmountException : OrganizerException() + class InsufficientTrayBalanceException : OrganizerException() + class InvalidSlotBalanceException : OrganizerException() + class InvalidStepIndexException : OrganizerException() + class SlotAtIndexEmptyException : OrganizerException() + } +} + +data class InternalExchange( + var from: AccountType, + var to: AccountType? = null, + var kin: Kin +) + +data class InternalDynamicStep( + var remaining: Kin, + var index: Int, + var exchanges: List +) + +data class InternalDeposit( + var to: SlotType, + var kin: Kin +) \ No newline at end of file diff --git a/services/flipchat/core/src/main/kotlin/xyz/flipchat/services/user/UserFlags.kt b/services/flipchat/core/src/main/kotlin/xyz/flipchat/services/user/UserFlags.kt new file mode 100644 index 000000000..e1bd920ae --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/xyz/flipchat/services/user/UserFlags.kt @@ -0,0 +1,11 @@ +package xyz.flipchat.services.user + +import com.getcode.model.Kin +import com.getcode.solana.keys.PublicKey + +data class UserFlags( + val isStaff: Boolean, + val createCost: Kin, + val feeDestination: PublicKey, + val isRegistered: Boolean, +) diff --git a/services/flipchat/core/src/main/kotlin/xyz/flipchat/services/user/UserManager.kt b/services/flipchat/core/src/main/kotlin/xyz/flipchat/services/user/UserManager.kt new file mode 100644 index 000000000..0b123c186 --- /dev/null +++ b/services/flipchat/core/src/main/kotlin/xyz/flipchat/services/user/UserManager.kt @@ -0,0 +1,185 @@ +package xyz.flipchat.services.user + +import com.bugsnag.android.Bugsnag +import com.getcode.crypt.DerivedKey +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.generator.OrganizerGenerator +import com.getcode.model.ID +import com.getcode.model.description +import com.getcode.model.uuid +import com.getcode.services.manager.MnemonicManager +import com.getcode.solana.organizer.Organizer +import com.getcode.utils.FormatUtils +import com.mixpanel.android.mpmetrics.MixpanelAPI +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import xyz.flipchat.services.core.BuildConfig +import javax.inject.Inject +import javax.inject.Singleton + +sealed interface AuthState { + data object Unknown : AuthState + data object Unregistered : AuthState + data object LoggedInAwaitingUser : AuthState + data object LoggedIn : AuthState + data object LoggedOut : AuthState + + fun canOpenChatStream() = this is Unregistered || this is LoggedIn +} + +@Singleton +class UserManager @Inject constructor( + private val mnemonicManager: MnemonicManager, + private val organizerGenerator: OrganizerGenerator, + private val mixpanelAPI: MixpanelAPI, +) { + private val _state: MutableStateFlow = MutableStateFlow(State()) + val state: StateFlow + get() = _state.asStateFlow() + + val entropy: String? + get() = _state.value.entropy + + val keyPair: KeyPair? + get() = _state.value.keyPair + + val userId: ID? + get() = _state.value.userId + + val organizer: Organizer? + get() = _state.value.organizer + + val displayName: String? + get() = _state.value.displayName + + val userFlags: UserFlags? + get() = _state.value.flags + + val openRoom: ID? + get() = _state.value.openRoom + + val authState: AuthState + get() = _state.value.authState + + data class State( + val authState: AuthState = AuthState.Unknown, + val entropy: String? = null, + val keyPair: KeyPair? = null, + val userId: ID? = null, + val displayName: String? = null, + val organizer: Organizer? = null, + val flags: UserFlags? = null, + val isTimelockUnlocked: Boolean = false, + val openRoom: ID? = null, + ) + + fun establish(entropy: String) { + val mnemonic = mnemonicManager.fromEntropyBase64(entropy) + val authority = DerivedKey.derive(com.getcode.crypt.DerivePath.primary, mnemonic) + val organizer = organizerGenerator.generate(mnemonic) + _state.update { + it.copy( + entropy = entropy, + keyPair = authority.keyPair, + organizer = organizer + ) + } + } + + fun set(userId: ID) { + _state.update { + it.copy(userId = userId) + } + associate() + } + + fun set(displayName: String) { + _state.update { + it.copy( + displayName = displayName + ) + } + associate() + } + + fun set(organizer: Organizer) { + _state.update { + it.copy(organizer = organizer) + } + } + + fun set(userFlags: UserFlags?) { + _state.update { + it.copy( + flags = userFlags + ) + } + associate() + } + + fun set(authState: AuthState) { + _state.update { it.copy(authState = authState) } + } + + fun roomOpened(roomId: ID) { + _state.update { + it.copy(openRoom = roomId) + } + } + + fun roomClosed() { + _state.update { + it.copy(openRoom = null) + } + } + + fun didDetectUnlockedAccount() { + _state.update { + if (!it.isTimelockUnlocked) { + it.copy(isTimelockUnlocked = true) + } else { + it + } + } + } + + fun isSelf(id: ID?) = userId == id + + private fun associate() { + if (!BuildConfig.DEBUG) { + val distinctId = userId?.uuid?.toString() + if (Bugsnag.isStarted()) { + Bugsnag.setUser(distinctId, null, displayName) + userFlags?.let { flags -> + Bugsnag.addMetadata( + /* section = */ "userflags", + /* value = */ mapOf( + "isStaff" to flags.isStaff, + "isRegistered" to flags.isRegistered, + "createCost" to FormatUtils.formatWholeRoundDown(flags.createCost.toKinValueDouble()) + ) + ) + } + } + + mixpanelAPI.identify(distinctId) + } + } + + fun clear() { + _state.update { + it.copy( + authState = AuthState.LoggedOut, + entropy = null, + keyPair = null, + userId = emptyList(), + organizer = null, + flags = null, + openRoom = null + ) + } + } + +} \ No newline at end of file diff --git a/services/flipchat/payments/.gitignore b/services/flipchat/payments/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/services/flipchat/payments/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/services/flipchat/payments/build.gradle.kts b/services/flipchat/payments/build.gradle.kts new file mode 100644 index 000000000..ebca671d9 --- /dev/null +++ b/services/flipchat/payments/build.gradle.kts @@ -0,0 +1,101 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.flipchatNamespace}.services.payments" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + + consumerProguardFiles("consumer-rules.pro") + + buildConfigField("String", "VERSION_NAME", "\"${Packaging.Flipchat.versionName}\"") + + javaCompileOptions { + annotationProcessorOptions { + arguments += mapOf("room.schemaLocation" to "$projectDir/schemas") + } + } + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } + + buildFeatures { + buildConfig = true + } +} + +dependencies { + implementation(project(":definitions:code-vm:models")) + implementation(project(":services:flipchat:core")) + api(project(":services:shared")) + implementation(project(":ui:resources")) + + implementation(project(":libs:messaging")) + implementation(project(":libs:requests")) + + implementation(platform(Libs.compose_bom)) + implementation(Libs.compose_ui) + + implementation(Libs.kotlinx_coroutines_core) + implementation(Libs.kotlinx_serialization_json) + implementation(Libs.inject) + + implementation(Libs.grpc_android) + implementation(Libs.grpc_okhttp) + implementation(Libs.grpc_kotlin) + implementation(Libs.androidx_lifecycle_runtime) + implementation(Libs.androidx_room_runtime) + implementation(Libs.androidx_room_ktx) + implementation(Libs.androidx_room_paging) + implementation(Libs.androidx_room_rxjava3) + implementation(Libs.okhttp) + implementation(Libs.mixpanel) + + implementation(platform(Libs.firebase_bom)) + implementation(Libs.firebase_crashlytics) + implementation(Libs.firebase_installations) + implementation(Libs.firebase_perf) + implementation(Libs.firebase_messaging) + + implementation(Libs.play_integrity) + + implementation(Libs.androidx_paging_runtime) + + kapt(Libs.androidx_room_compiler) + implementation(Libs.sqlcipher) + + implementation(Libs.fingerprint_pro) + + implementation(Libs.lib_phone_number_google) + + androidTestImplementation(Libs.androidx_junit) + androidTestImplementation(Libs.junit) + androidTestImplementation(Libs.androidx_test_runner) + + implementation(Libs.hilt) + kapt(Libs.hilt_android_compiler) + kapt(Libs.hilt_compiler) + + implementation(Libs.timber) + implementation(Libs.bugsnag) +} diff --git a/services/flipchat/payments/consumer-rules.pro b/services/flipchat/payments/consumer-rules.pro new file mode 100644 index 000000000..5f642a47c --- /dev/null +++ b/services/flipchat/payments/consumer-rules.pro @@ -0,0 +1,6 @@ +# Needed to keep generic signatures +-keepattributes Signature + +-keepclasseswithmembernames class * { + native ; +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/AndroidManifest.xml b/services/flipchat/payments/src/main/AndroidManifest.xml new file mode 100644 index 000000000..8b1db4200 --- /dev/null +++ b/services/flipchat/payments/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/db/ExchangeDao.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/db/ExchangeDao.kt new file mode 100644 index 000000000..407b2700c --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/db/ExchangeDao.kt @@ -0,0 +1,29 @@ +package com.getcode.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.getcode.model.Rate +import com.getcode.model.ExchangeRate +import kotlinx.coroutines.flow.Flow + +@Dao +interface ExchangeDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(rates: List, syncedAt: Long) { + insert(*rates.map { ExchangeRate(it.fx, it.currency, syncedAt) }.toTypedArray()) + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(vararg rate: ExchangeRate) + + @Query("SELECT * FROM exchangeData") + fun observeRates(): Flow> + + @Query("SELECT * FROM exchangeData") + suspend fun query(): List + + @Query("DELETE FROM exchangeData") + fun clear() +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/generator/GiftCardGenerator.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/generator/GiftCardGenerator.kt new file mode 100644 index 000000000..45ce169ee --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/generator/GiftCardGenerator.kt @@ -0,0 +1,12 @@ +package com.getcode.generator + +import com.getcode.crypt.MnemonicPhrase +import com.getcode.solana.organizer.GiftCardAccount +import javax.inject.Inject + +class GiftCardGenerator @Inject constructor( +): Generator { + override fun generate(predicate: MnemonicPhrase?): GiftCardAccount { + return GiftCardAccount.newInstance(predicate) + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/inject/CodeProxyApiModule.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/inject/CodeProxyApiModule.kt new file mode 100644 index 000000000..bfbc0bedc --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/inject/CodeProxyApiModule.kt @@ -0,0 +1,35 @@ +package com.getcode.inject + +import com.getcode.network.exchange.CodeExchange +import com.getcode.network.exchange.Exchange +import com.getcode.network.repository.BalanceRepository +import com.getcode.network.service.CurrencyService +import com.getcode.services.db.CurrencyProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object CodeProxyApiModule { + + @Singleton + @Provides + fun providesExchange( + currencyService: CurrencyService, + currencyProvider: CurrencyProvider, + ): Exchange = CodeExchange( + currencyService = currencyService, + preferredCurrency = { currencyProvider.preferredCurrency() }, + defaultCurrency = { currencyProvider.defaultCurrency() } + ) + + @Singleton + @Provides + fun provideBalanceRepository( + ): BalanceRepository { + return BalanceRepository() + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/manager/GiftCardManager.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/manager/GiftCardManager.kt new file mode 100644 index 000000000..2ee1c5e41 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/manager/GiftCardManager.kt @@ -0,0 +1,18 @@ +package com.getcode.manager + +import com.getcode.crypt.MnemonicPhrase +import com.getcode.generator.GiftCardGenerator +import com.getcode.solana.organizer.GiftCardAccount +import javax.inject.Inject + +class GiftCardManager @Inject constructor( + private val generator: GiftCardGenerator +) { + fun createGiftCard(mnemonic: MnemonicPhrase? = null): GiftCardAccount { + return generator.generate(mnemonic) + } + + fun getEntropy(giftCard: GiftCardAccount): String { + return giftCard.mnemonicPhrase.getBase58EncodedEntropy() + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/manager/ModalManager.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/manager/ModalManager.kt new file mode 100644 index 000000000..b6e1f9889 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/manager/ModalManager.kt @@ -0,0 +1,59 @@ +package com.getcode.manager + +import androidx.annotation.DrawableRes +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.util.UUID + +object ModalManager { + data class Message( + @DrawableRes + val icon: Int? = null, + val title: String, + val subtitle: String = "", + val positiveText: String, + val negativeText: String? = null, + val tertiaryText: String? = null, + val onPositive: () -> Unit, + val onNegative: () -> Unit = {}, + val onTertiary: () -> Unit = {}, + val onClose: (actionType: ActionType?) -> Unit = {}, + val type: MessageType = MessageType.DEFAULT, +// val isDismissibleByTouchOutside: Boolean = true, + val isDismissibleByBackButton: Boolean = true, + val timeoutSeconds: Int? = null, + val id: Long = UUID.randomUUID().mostSignificantBits, + ) + + private val _messages: MutableStateFlow> = MutableStateFlow( + listOf() + ) + val messages: StateFlow> get() = _messages.asStateFlow() + + fun showMessage(message: Message) { + _messages.update { currentMessages -> + currentMessages + message + } + } + + fun setMessageShown(messageId: Long) { + _messages.update { currentMessages -> + currentMessages.filterNot { it.id == messageId } + } + } + + fun clear() = _messages.update { listOf() } + + fun clearByType(type: MessageType) = _messages.update { it.filterNot { m -> m.type == type } } + + enum class MessageType { DEFAULT } + + enum class ActionType { + Positive, + Negative, + Tertiary + } + +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/AirdropType.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/AirdropType.kt new file mode 100644 index 000000000..19185c5d1 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/AirdropType.kt @@ -0,0 +1,21 @@ +package com.getcode.model + + +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService + +enum class AirdropType { + Unknown, + GiveFirstKin, + GetFirstKin; + + companion object { + fun getInstance(airdropType: TransactionService.AirdropType): AirdropType? { + return when (airdropType) { + TransactionService.AirdropType.UNKNOWN -> Unknown + TransactionService.AirdropType.GIVE_FIRST_KIN -> GiveFirstKin + TransactionService.AirdropType.GET_FIRST_KIN -> GetFirstKin + TransactionService.AirdropType.UNRECOGNIZED -> null + } + } + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/ClientSignature.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/ClientSignature.kt new file mode 100644 index 000000000..bcb751ce8 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/ClientSignature.kt @@ -0,0 +1,8 @@ +package com.getcode.model + +import com.getcode.solana.keys.Signature + +data class ClientSignature( + val transaction: Signature, + val signature: Signature +) diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/CurrencyRate.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/CurrencyRate.kt new file mode 100644 index 000000000..a31b89562 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/CurrencyRate.kt @@ -0,0 +1,10 @@ +package com.getcode.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class CurrencyRate( + @PrimaryKey val id: String, + val rate: Double +) \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/ExchangeRate.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/ExchangeRate.kt new file mode 100644 index 000000000..4795a4508 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/ExchangeRate.kt @@ -0,0 +1,28 @@ +package com.getcode.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import kotlinx.serialization.Serializable + + +@Serializable +@Entity(tableName = "exchangeData") +data class ExchangeRate( + @ColumnInfo(name = "fiat") + val fx: Double, + @PrimaryKey + val currency: CurrencyCode, + @ColumnInfo(name = "synced_at") + val synced: Long, +) + +fun KinAmount.Companion.fromProtoExchangeData(exchangeData: TransactionService.ExchangeData): KinAmount { + return fromFiatAmount( + kin = Kin(exchangeData.quarks), + fiat = exchangeData.nativeAmount, + fx = exchangeData.exchangeRate, + currencyCode = CurrencyCode.tryValueOf(exchangeData.currency)!! + ) +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/FaqItem.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/FaqItem.kt new file mode 100644 index 000000000..1ed2ea70b --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/FaqItem.kt @@ -0,0 +1,12 @@ +package com.getcode.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + + +@Entity +data class FaqItem( + @PrimaryKey(autoGenerate = true) val uid: Int? = null, + val question: String, + val answer: String + ) \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/GiftCard.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/GiftCard.kt new file mode 100644 index 000000000..54f9bb1e8 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/GiftCard.kt @@ -0,0 +1,12 @@ +package com.getcode.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class GiftCard( + @PrimaryKey val key: String, //base58 + val entropy: String, //base58 + val amount: Long, + val date: Long +) \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/Intent.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/Intent.kt new file mode 100644 index 000000000..cd453ceab --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/Intent.kt @@ -0,0 +1,15 @@ +package com.getcode.model + +sealed class Intent { + data class Transfer( + val id: List, + val amount: KinAmount, + val source: List, + val destination: SendDestination + ) : Intent() + + data class CreateTokenAccount( + val id: List, + val owner: List + ) : Intent() +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/IntentMetadata.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/IntentMetadata.kt new file mode 100644 index 000000000..8819907ab --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/IntentMetadata.kt @@ -0,0 +1,81 @@ +package com.getcode.model + +import com.getcode.solana.keys.PublicKey +import com.getcode.utils.toByteString +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService + +sealed class IntentMetadata { + data object OpenAccounts : IntentMetadata() + data class SendPrivatePayment(val metadata: PaymentMetadata) : IntentMetadata() + data class SendPublicPayment(val metadata: PaymentMetadata) : IntentMetadata() + data object ReceivePaymentsPrivately : IntentMetadata() + data class ReceivePaymentsPublicly(val metadata: PaymentMetadata) : IntentMetadata() + data object UpgradePrivacy : IntentMetadata() + + companion object { + fun newInstance(metadata: TransactionService.Metadata): IntentMetadata? { + return when (metadata.typeCase) { + TransactionService.Metadata.TypeCase.OPEN_ACCOUNTS -> OpenAccounts + TransactionService.Metadata.TypeCase.RECEIVE_PAYMENTS_PRIVATELY -> ReceivePaymentsPrivately + TransactionService.Metadata.TypeCase.RECEIVE_PAYMENTS_PUBLICLY -> { + getPaymentMetadata( + metadata.receivePaymentsPublicly.exchangeData.currency, + metadata.receivePaymentsPublicly.exchangeData.quarks, + metadata.receivePaymentsPublicly.exchangeData.exchangeRate, + false, + null + )?.let { ReceivePaymentsPublicly(it) } + } + TransactionService.Metadata.TypeCase.UPGRADE_PRIVACY -> UpgradePrivacy + TransactionService.Metadata.TypeCase.SEND_PRIVATE_PAYMENT -> { + getPaymentMetadata( + metadata.sendPrivatePayment.exchangeData.currency, + metadata.sendPrivatePayment.exchangeData.quarks, + metadata.sendPrivatePayment.exchangeData.exchangeRate, + metadata.sendPrivatePayment.isTip, + metadata.sendPrivatePayment.destination.toByteArray() + )?.let { SendPrivatePayment(it) } + } + TransactionService.Metadata.TypeCase.SEND_PUBLIC_PAYMENT -> { + getPaymentMetadata( + metadata.sendPublicPayment.exchangeData.currency, + metadata.sendPublicPayment.exchangeData.quarks, + metadata.sendPublicPayment.exchangeData.exchangeRate, + false, + metadata.sendPrivatePayment.destination.toByteArray() + )?.let { SendPublicPayment(it) } + } + else -> null + } + } + + private fun getPaymentMetadata( + currencyString: String, + quarks: Long, + exchangeRate: Double, + isTip: Boolean, + destination: ByteArray?, + ): PaymentMetadata? { + val currency = CurrencyCode.tryValueOf(currencyString.uppercase()) + ?: return null + + return PaymentMetadata( + amount = KinAmount.newInstance( + kin = Kin(quarks), + rate = Rate( + fx = exchangeRate, + currency = currency + ) + ), + isTip = isTip, + destination = destination?.let { PublicKey.fromByteString(it.toByteString()) }, + ) + } + } +} + +data class PaymentMetadata( + val amount: KinAmount, + val isTip: Boolean, + val destination: PublicKey? +) \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/KinCode.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/KinCode.kt new file mode 100644 index 000000000..7d6e329b4 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/KinCode.kt @@ -0,0 +1,81 @@ +package com.getcode.model + +import org.kin.sdk.base.models.QuarkAmount +import org.kin.sdk.base.models.solana.read +import org.kin.sdk.base.tools.byteArrayToLong +import org.kin.sdk.base.tools.longToByteArray +import org.kin.sdk.base.tools.toByteArray +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.UUID + + +data class KinCode(val type: Type = Type.None, val amount: QuarkAmount, val nonce: Nonce) { + + sealed class Type(val value: Int) { + object Unknown : Type(-1) + object None : Type(0) + + companion object { + @JvmStatic + fun from(value: Int): Type { + return when (value) { + 0 -> None + else -> Unknown + } + } + + } + } + + /** + * Only 11 Bytes LSB observed + */ + data class Nonce(val value: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Nonce) return false + + if (!value.contentEquals(other.value)) return false + + return true + } + + override fun hashCode(): Int { + return value.contentHashCode() + } + + companion object { + fun random(): Nonce { + return Nonce(UUID.randomUUID().toByteArray()) + } + } + } + + /** + * 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 + * +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ + * |T | Amount | Nonce | + * +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ + */ + fun encode(): ByteArray { + return with(ByteArrayOutputStream()) { + write(byteArrayOf(type.value.toByte())) + write(amount.value.longToByteArray()) + write(nonce.value, 0, 11) + toByteArray() + } + } + + companion object { + @JvmStatic + fun decode(bytes: ByteArray): KinCode { + return with(ByteArrayInputStream(bytes)) { + val type = Type.from(read()) + val amount = QuarkAmount(read(8).byteArrayToLong()) + val nonce = Nonce(read(11)) + KinCode(type, amount, nonce) + } + } + } +} diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/Limits.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/Limits.kt new file mode 100644 index 000000000..352207298 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/Limits.kt @@ -0,0 +1,98 @@ +package com.getcode.model + +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import com.codeinc.gen.transaction.v2.CodeTransactionService.BuyModuleLimit +import com.codeinc.gen.transaction.v2.CodeTransactionService.DepositLimit +import kotlin.time.Duration.Companion.hours + +data class Limits( + // Date from which the limits are computed + val sinceDate : Long, + + // Date at which the limits were fetched + val fetchDate: Long, + + // Maximum quarks that may be deposited at any time. Server will guarantee + // this threshold will be below enforced dollar value limits, while also + // ensuring sufficient funds are available for a full organizer that supports + // max payment sends. Total dollar value limits may be spread across many deposits. + val maxDeposit: Kin, + + // Remaining send limits keyed by currency + private val sendLimits: Map, + // Buy limits keyed by currency + private val buyLimits: Map, + + ) { + val isStale: Boolean + get() { + val now = System.currentTimeMillis() + return now - fetchDate > 1.hours.inWholeMilliseconds + } + + fun sendLimitFor(currencyCode: CurrencyCode) : SendLimit? { + return sendLimits[currencyCode] + } + + fun buyLimitFor(currencyCode: CurrencyCode): BuyLimit? { + return buyLimits[currencyCode] + } + + companion object { + fun newInstance( + sinceDate: Long, + fetchDate: Long, + sendLimits: Map, + buyLimits: Map, + deposits: DepositLimit, + ): Limits { + val sends = sendLimits + .mapNotNull { (k, v) -> + val code = CurrencyCode.tryValueOf(k) ?: return@mapNotNull null + val limit = SendLimit( + nextTransaction = v.nextTransaction.toDouble(), + maxPerDay = v.maxPerDay.toDouble(), + maxPerTransaction = v.maxPerTransaction.toDouble(), + ) + + code to limit + }.toMap() + + val buys = buyLimits + .mapValues { (_, v) -> + BuyLimit( + min = v.minPerTransaction.toDouble(), + max = v.maxPerTransaction.toDouble() + ) + } + .mapNotNull { (k, limit) -> + val code = CurrencyCode.tryValueOf(k) ?: return@mapNotNull null + code to limit + }.toMap() + + return Limits( + sinceDate = sinceDate, + fetchDate = fetchDate, + sendLimits = sends, + buyLimits = buys, + maxDeposit = Kin.fromQuarks(deposits.maxQuarks) + ) + } + } +} + +data class SendLimit( + val nextTransaction: Double, + val maxPerTransaction: Double, + val maxPerDay: Double +) { + companion object { + val Zero = SendLimit(0.0, 0.0, 0.0) + } +} + +data class BuyLimit(val min: Double, val max: Double) { + companion object { + val Zero = BuyLimit(0.0, 0.0) + } +} diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/PaymentRequest.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/PaymentRequest.kt new file mode 100644 index 000000000..54a92a4d5 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/PaymentRequest.kt @@ -0,0 +1,199 @@ +package com.getcode.model + +import com.codeinc.gen.messaging.v1.CodeMessagingService as MessagingService +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.Signature + +data class StreamMessage(val id: List, val kind: Kind) { + sealed interface Kind { + class ReceiveRequestKind(val receiveRequest: ReceiveRequest): Kind + class PaymentRequestKind(val paymentRequest: PaymentRequest) : Kind + class AirdropKind(val airdrop: Airdrop) : Kind + data class LoginRequestKind(val loginRequest: LoginRequest): Kind + } + + val receiveRequest: ReceiveRequest? = (kind as? Kind.ReceiveRequestKind)?.receiveRequest + val paymentRequest: PaymentRequest? = (kind as? Kind.PaymentRequestKind)?.paymentRequest + val loginRequest: LoginRequest? = (kind as? Kind.LoginRequestKind)?.loginRequest + val airdrop: Airdrop? = (kind as? Kind.AirdropKind)?.airdrop + + companion object { + fun getInstance(message: MessagingService.Message): StreamMessage? { + val kind: Kind = when (message.kindCase) { + MessagingService.Message.KindCase.REQUEST_TO_GRAB_BILL -> { + val account = + PublicKey( + message.requestToGrabBill.requestorAccount.value.toByteArray().toList() + ) + val signature = + Signature( + message.sendMessageRequestSignature.value.toByteArray().toList() + ) + + Kind.PaymentRequestKind( + PaymentRequest( + account = account, + signature = signature + ) + ) + } + MessagingService.Message.KindCase.REQUEST_TO_RECEIVE_BILL -> { + val request = message.requestToReceiveBill + val exchangeData = request.exchangeDataCase + val account = PublicKey( + request.requestorAccount.value.toByteArray().toList() + ) + val signature = Signature( + message.sendMessageRequestSignature.value.toByteArray().toList() + ) + + val domain: Domain? + val verifier: PublicKey? + if (request.hasDomain()) { + val validDomain = Domain.from(request.domain.value) ?: return null + val validVerifier = PublicKey( + request.verifier.value.toByteArray().toList() + ) + + domain = validDomain + verifier = validVerifier + } else { + domain = null + verifier = null + } + + val requestData = when (exchangeData) { + MessagingService.RequestToReceiveBill.ExchangeDataCase.EXACT -> { + val data = request.exact + val currency = CurrencyCode.tryValueOf(data.currency) ?: return null + + val additionalFees = request.additionalFeesList.mapNotNull { + val destination = PublicKey( + it.destination.value.toByteArray().toList() + ) + Fee(destination = destination, it.feeBps) + } + + ReceiveRequest( + account = account, + signature = signature, + amount = ReceiveRequest.Amount.Exact( + value = KinAmount.newInstance( + kin = Kin(data.quarks), + rate = Rate( + fx = data.exchangeRate, + currency = currency + ) + ) + ), + domain = domain, + verifier = verifier, + additionalFees = additionalFees, + ) + } + MessagingService.RequestToReceiveBill.ExchangeDataCase.PARTIAL -> { + val data = request.partial + val currency = CurrencyCode.tryValueOf(data.currency) ?: return null + + + val additionalFees = request.additionalFeesList.mapNotNull { + val destination = PublicKey( + it.destination.value.toByteArray().toList() + ) + Fee(destination = destination, it.feeBps) + } + + ReceiveRequest( + account = account, + signature = signature, + amount = ReceiveRequest.Amount.Partial( + value = Fiat(currency, data.nativeAmount) + ), + domain = domain, + verifier = verifier, + additionalFees + ) + } + else -> return null + } + + Kind.ReceiveRequestKind(requestData) + } + MessagingService.Message.KindCase.AIRDROP_RECEIVED -> { + val type = AirdropType.getInstance(message.airdropReceived.airdropType) + ?: return null + val currency = CurrencyCode.tryValueOf(message.airdropReceived.exchangeData.currency) + ?: return null + + Kind.AirdropKind( + Airdrop( + type = type, + date = message.airdropReceived.timestamp.seconds, + kinAmount = KinAmount.newInstance( + kin = Kin(message.airdropReceived.exchangeData.quarks), + rate = Rate( + fx = message.airdropReceived.exchangeData.exchangeRate, + currency = currency + ) + ) + ) + ) + return null + } + + MessagingService.Message.KindCase.REQUEST_TO_LOGIN -> { + val request = message.requestToLogin + val domain = request.domain?.let { Domain.from(it.value) } ?: return null + val verifier = PublicKey( + request.verifier.value.toByteArray().toList() + ) + val rendezvous = PublicKey( + request.rendezvousKey.toByteArray().toList() + ) + val signature = Signature( + request.signature.value.toByteArray().toList() + ) + + Kind.LoginRequestKind( + LoginRequest(domain, verifier, rendezvous, signature) + ) + } + else -> return null + } + return StreamMessage( + id = message.id.value.toByteArray().toList(), + kind = kind + ) + } + } +} + +data class ReceiveRequest( + val account: PublicKey, + val signature: Signature, + val amount: Amount, + val domain: Domain?, + val verifier: PublicKey?, + val additionalFees: List +) { + sealed interface Amount { + data class Exact(val value: KinAmount): Amount + data class Partial(val value: Fiat): Amount + } +} + +data class PaymentRequest(val account: PublicKey, val signature: Signature) + +data class LoginRequest( + val domain: Domain, + val verifier: PublicKey, + val rendezous: PublicKey, + val signature: Signature, +) + +data class Airdrop(val type: AirdropType, val date: Long, val kinAmount: KinAmount) + +data class Fee( + val destination: PublicKey, + val bps: Int, +) \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/SendDestination.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/SendDestination.kt new file mode 100644 index 000000000..ac195e34c --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/SendDestination.kt @@ -0,0 +1,11 @@ +package com.getcode.model + +sealed class SendDestination { + data class SendDestinationPublicKey( + val publicKey: List, + ) : SendDestination() + + data class SendDestinationPhone( + val phoneNumber: String + ) : SendDestination() +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/StreamEvent.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/StreamEvent.kt new file mode 100644 index 000000000..cdecbb7b8 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/StreamEvent.kt @@ -0,0 +1,14 @@ +package com.getcode.model + +sealed class StreamEvent(val id: String) { + class SimulationEvent( + id: String, + val isFailed: Boolean, + val rendezvousKey: ByteArray, + val exchangeCurrency: String?, + val exchangeRate: Double?, + val amountNative: Double?, + val kin: Kin?, + val region: String? + ) : StreamEvent(id) +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/UpgradeableIntent.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/UpgradeableIntent.kt new file mode 100644 index 000000000..fd16cec6e --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/UpgradeableIntent.kt @@ -0,0 +1,23 @@ +package com.getcode.model + +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import com.getcode.solana.keys.PublicKey + +class UpgradeableIntent( + val id: PublicKey, + val actions: List, +) { + companion object { + fun newInstance(proto: TransactionService.UpgradeableIntent): UpgradeableIntent { + val intentId = PublicKey(proto.id.value.toByteArray().toList()) + + val actions = proto.actionsList.map { + UpgradeablePrivateAction.newInstance(it) + } + + return UpgradeableIntent(intentId, actions) + } + + } + +} diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/UpgradeablePrivateAction.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/UpgradeablePrivateAction.kt new file mode 100644 index 000000000..f587b16b4 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/UpgradeablePrivateAction.kt @@ -0,0 +1,107 @@ +package com.getcode.model + +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import com.getcode.solana.SolanaTransaction +import com.getcode.solana.instructions.programs.SystemProgram_AdvanceNonce +import com.getcode.solana.instructions.programs.TimelockProgram_TransferWithAuthority +import com.getcode.solana.keys.Hash +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.Signature +import com.getcode.solana.organizer.AccountType + +class UpgradeablePrivateAction( + val id: Int, + val transactionBlob: List, + val clientSignature: Signature, + val sourceAccountType: AccountType, + val sourceDerivationIndex: Long, + val originalDestination: PublicKey, + val originalAmount: Kin, + val treasury: PublicKey, + val recentRoot: Hash, + + val transaction: SolanaTransaction, + val originalNonce: PublicKey, + val originalCommitment: PublicKey, + val originalRecentBlockhash: Hash +) { + companion object { + fun newInstance( + id: Int, + transactionBlob: List, + clientSignature: Signature, + sourceAccountType: AccountType, + sourceDerivationIndex: Long, + originalDestination: PublicKey, + originalAmount: Kin, + treasury: PublicKey, + recentRoot: Hash + ): UpgradeablePrivateAction { + val transaction: SolanaTransaction = SolanaTransaction.fromList(transactionBlob) + ?: throw UpgradeablePrivateActionException.FailedToParseTransactionException() + + val nonceInstruction = + transaction.findInstruction(SystemProgram_AdvanceNonce::newInstance) + ?: throw UpgradeablePrivateActionException.MissingOriginalNonceException() + + val transferInstruction = + transaction.findInstruction( + TimelockProgram_TransferWithAuthority::newInstance + ) + ?: throw UpgradeablePrivateActionException.MissingOriginalCommitmentException() + + + return UpgradeablePrivateAction( + id = id, + transactionBlob = transactionBlob, + clientSignature = clientSignature, + sourceAccountType = sourceAccountType, + sourceDerivationIndex = sourceDerivationIndex, + originalDestination = originalDestination, + originalAmount = originalAmount, + treasury = treasury, + recentRoot = recentRoot, + transaction = transaction, + originalNonce = nonceInstruction.nonce, + originalCommitment = transferInstruction.destination, + originalRecentBlockhash = transaction.recentBlockhash + ) + } + + fun newInstance(proto: TransactionService.UpgradeableIntent.UpgradeablePrivateAction): UpgradeablePrivateAction { + val signature = Signature( + proto.clientSignature.value.toByteArray().toList() + ) + val accountType = + AccountType.newInstance(proto.sourceAccountType) + ?: throw UpgradeablePrivateActionException.DeserializationFailedException() + val originalDestination = + PublicKey( + proto.originalDestination.value.toByteArray().toList() + ) + val treasury = + PublicKey(proto.treasury.value.toByteArray().toList()) + val recentRoot = + Hash(proto.recentRoot.value.toByteArray().toList()) + + return newInstance( + id = proto.actionId, + transactionBlob = proto.transactionBlob.value.toByteArray().toList(), + clientSignature = signature, + sourceAccountType = accountType, + sourceDerivationIndex = proto.sourceDerivationIndex, + originalDestination = originalDestination, + originalAmount = Kin.fromQuarks(proto.originalAmount), + treasury = treasury, + recentRoot = recentRoot + ) + } + } + + sealed class UpgradeablePrivateActionException : Exception() { + class MissingOriginalNonceException : UpgradeablePrivateActionException() + class MissingOriginalCommitmentException : UpgradeablePrivateActionException() + class FailedToParseTransactionException : UpgradeablePrivateActionException() + class DeserializationFailedException : UpgradeablePrivateActionException() + } +} diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentCreateAccounts.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentCreateAccounts.kt new file mode 100644 index 000000000..f5479b347 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentCreateAccounts.kt @@ -0,0 +1,47 @@ +package com.getcode.model.intents + +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import com.getcode.ed25519.Ed25519 +import com.getcode.model.intents.actions.ActionOpenAccount +import com.getcode.model.intents.actions.ActionType +import com.getcode.model.toPublicKey +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.organizer.Organizer + +class IntentCreateAccounts( + override val id: PublicKey, + override val actionGroup: ActionGroup, + private val organizer: Organizer, +) : IntentType() { + + override fun metadata(): TransactionService.Metadata { + return TransactionService.Metadata.newBuilder() + .setOpenAccounts(TransactionService.OpenAccountsMetadata.getDefaultInstance()) + .build() + } + + + companion object { + fun newInstance(organizer: Organizer): IntentCreateAccounts { + val actionsList = mutableListOf().apply { + organizer.allAccounts().map { pair -> + val (type, cluster) = pair + ActionOpenAccount.newInstance( + owner = organizer.tray.owner.getCluster().authority.keyPair.publicKeyBytes.toPublicKey(), + type = type, + accountCluster = cluster + ).let { this.add(it) } + } + } + + return IntentCreateAccounts( + id = Ed25519.createKeyPair().publicKeyBytes.toPublicKey(), + organizer = organizer, + actionGroup = ActionGroup().apply { + actions = actionsList + } + ) + + } + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentDeposit.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentDeposit.kt new file mode 100644 index 000000000..82aa15348 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentDeposit.kt @@ -0,0 +1,94 @@ +package com.getcode.model.intents + +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import com.getcode.model.Kin +import com.getcode.model.generate +import com.getcode.model.intents.actions.ActionTransfer +import com.getcode.network.repository.toSolanaAccount +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.organizer.AccountType +import com.getcode.solana.organizer.Organizer +import com.getcode.solana.organizer.Tray + +class IntentDeposit( + override val id: PublicKey, + private val organizer: Organizer, + private val amount: Kin, + private val source: AccountType, + val resultTray: Tray, + + override val actionGroup: ActionGroup, +) : IntentType() { + override fun metadata(): TransactionService.Metadata { + return TransactionService.Metadata.newBuilder() + .setReceivePaymentsPrivately( + TransactionService.ReceivePaymentsPrivatelyMetadata.newBuilder() + .setSource(organizer.tray.cluster(source).vaultPublicKey.bytes.toSolanaAccount()) + .setQuarks(amount.quarks) + .setIsDeposit(true) + ) + .build() + } + + companion object { + fun newInstance( + source: AccountType, + organizer: Organizer, + amount: Kin + ): IntentDeposit { + val intentId = PublicKey.generate() + val currentTray = organizer.tray.copy() + val startSlotBalance = currentTray.slotsBalance + + // 1. Move all funds from the primary + // account to appropriate slots + + val transfers = currentTray.receive(receivingAccount = source, amount = amount).map { transfer -> + ActionTransfer.newInstance( + kind = ActionTransfer.Kind.TempPrivacyTransfer, + intentId = intentId, + amount = transfer.kin, + source = currentTray.cluster(transfer.from), + destination = currentTray.cluster(transfer.to!!).vaultPublicKey + ) + } + + // 2. Redistribute the funds to prepare for + // future transfers + + val redistributes = currentTray.redistribute().map { exchange -> + ActionTransfer.newInstance( + kind = ActionTransfer.Kind.TempPrivacyExchange, + intentId = intentId, + amount = exchange.kin, + source = currentTray.cluster(exchange.from), + destination = currentTray.cluster(exchange.to!!).vaultPublicKey // Exchanges always provide destination accounts + ) + } + + val endSlotBalance = currentTray.slotsBalance + + // Ensure that balances are consistent + // with what we expect these action to do + if (endSlotBalance - startSlotBalance != amount) { + throw IntentReceive.Companion.IntentReceiveException.BalanceMismatchException() + } + + val group = ActionGroup().apply { + actions = listOf( + *transfers.toTypedArray(), + *redistributes.toTypedArray() + ) + } + + return IntentDeposit( + id = intentId, + source = source, + organizer = organizer, + amount = amount, + actionGroup = group, + resultTray = currentTray + ) + } + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentEstablishRelationship.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentEstablishRelationship.kt new file mode 100644 index 000000000..70e0219cf --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentEstablishRelationship.kt @@ -0,0 +1,61 @@ +package com.getcode.model.intents + +import com.codeinc.gen.common.v1.CodeModel as Model +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import com.getcode.model.Domain +import com.getcode.model.generate +import com.getcode.model.intents.actions.ActionOpenAccount +import com.getcode.model.toPublicKey +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.organizer.AccountType +import com.getcode.solana.organizer.Organizer +import com.getcode.solana.organizer.Relationship +import com.getcode.solana.organizer.Tray + +class IntentEstablishRelationship( + override val id: PublicKey, + override val actionGroup: ActionGroup, + val organizer: Organizer, + val domain: Domain, + val resultTray: Tray, + val relationship: Relationship, +): IntentType() { + override fun metadata(): TransactionService.Metadata { + return TransactionService.Metadata.newBuilder() + .setEstablishRelationship( + TransactionService.EstablishRelationshipMetadata.newBuilder() + .setRelationship( + Model.Relationship.newBuilder() + .setDomain(Model.Domain.newBuilder().setValue(domain.relationshipHost)) + ) + ) + .build() + } + + companion object { + fun newInstance(organizer: Organizer, domain: Domain): IntentEstablishRelationship { + val id = PublicKey.generate() + val currentTray = organizer.tray.copy() + + val relationship = currentTray.createRelationship(domain) + + val ownerKey = currentTray.owner.getCluster().authority.keyPair.publicKeyBytes.toPublicKey() + val actionOpenAccount = ActionOpenAccount.newInstance( + owner = ownerKey, + type = AccountType.Relationship(domain), + accountCluster = relationship.getCluster() + ) + + return IntentEstablishRelationship( + id = id, + organizer = organizer, + domain = domain, + actionGroup = ActionGroup().apply { + actions = listOf(actionOpenAccount) + }, + resultTray = currentTray, + relationship = relationship, + ) + } + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentPrivateTransfer.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentPrivateTransfer.kt new file mode 100644 index 000000000..c2cc11ddf --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentPrivateTransfer.kt @@ -0,0 +1,232 @@ +package com.getcode.model.intents + +import com.codeinc.gen.chat.v2.ChatService +import com.getcode.model.Fee +import com.getcode.model.Kin +import com.getcode.model.KinAmount +import com.getcode.model.chat.Platform +import com.getcode.model.intents.actions.ActionFeePayment +import com.getcode.model.intents.actions.ActionOpenAccount +import com.getcode.model.intents.actions.ActionTransfer +import com.getcode.model.intents.actions.ActionWithdraw +import com.getcode.model.toPublicKey +import com.getcode.network.repository.toSolanaAccount +import com.getcode.services.model.ExtendedMetadata +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.organizer.AccountType +import com.getcode.solana.organizer.Organizer +import com.getcode.solana.organizer.Tray +import timber.log.Timber +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService + +class IntentPrivateTransfer( + override val id: PublicKey, + private val organizer: Organizer, + private val destination: PublicKey, + // Amount requested to transfer + private val grossAmount: KinAmount, + // Amount after fees are paid + private val netAmount: KinAmount, + private val fee: Kin, + private val additionalFees: List, + private val isWithdrawal: Boolean, + private val metadata: ExtendedMetadata?, + val resultTray: Tray, + + override val actionGroup: ActionGroup, +) : IntentType() { + override fun metadata(): TransactionService.Metadata { + return TransactionService.Metadata.newBuilder() + .setSendPrivatePayment( + TransactionService.SendPrivatePaymentMetadata.newBuilder().apply { + setDestination(this@IntentPrivateTransfer.destination.bytes.toSolanaAccount()) + setIsWithdrawal(this@IntentPrivateTransfer.isWithdrawal) + setExchangeData( + TransactionService.ExchangeData.newBuilder() + .setQuarks(grossAmount.kin.quarks) + .setCurrency(grossAmount.rate.currency.name.lowercase()) + .setExchangeRate(grossAmount.rate.fx) + .setNativeAmount(grossAmount.fiat) + ) + + when (metadata) { + is ExtendedMetadata.Tip -> { + setIsTip(true) + setTippedUser(TransactionService.TippedUser.newBuilder() + .setPlatformValue(when (Platform.named(metadata.socialUser.platform)) { + Platform.Unknown -> ChatService.Platform.UNKNOWN_PLATFORM_VALUE + Platform.Twitter -> ChatService.Platform.TWITTER_VALUE + }) + .setUsername(metadata.socialUser.username) + ) + } + else -> Unit + } + } + ) + .build() + } + + companion object { + fun newInstance( + rendezvousKey: PublicKey, + organizer: Organizer, + destination: PublicKey, + amount: KinAmount, + fee: Kin, + additionalFees: List, + isWithdrawal: Boolean, + metadata: ExtendedMetadata?, + ): IntentPrivateTransfer { + if (fee > amount.kin) { + throw IntentPrivateTransferException.InvalidFeeException() + } + + // Compute all the fees that will be + // paid out of this transaction + val concreteFees = additionalFees.map { + val _fee = amount.kin.calculateFee(it.bps) + _fee to it.destination + } + + var netKin = amount.kin - fee + + // Apply the fee to the gross amount + concreteFees.onEach { (fee, destination) -> + netKin -= fee + } + + val netAmount = KinAmount.newInstance(kin = netKin, rate = amount.rate) + + val currentTray = organizer.tray.copy() + val startBalance = currentTray.availableBalance + + // 1. Move all funds from bucket accounts into the + // outgoing account and prepare to transfer + + val transfers = currentTray.transfer(amount = amount.kin).map { transfer -> + val sourceCluster = currentTray.cluster(transfer.from) + + // If the transfer is to another bucket, it's an internal + // exchange. Otherwise, it is considered a transfer. + if (transfer.to is AccountType.Bucket) { + ActionTransfer.newInstance( + kind = ActionTransfer.Kind.TempPrivacyExchange, + intentId = rendezvousKey, + amount = transfer.kin, + source = sourceCluster, + destination = currentTray.slot((transfer.to as AccountType.Bucket).type).getCluster().vaultPublicKey + ) + } else { + ActionTransfer.newInstance( + kind = ActionTransfer.Kind.TempPrivacyTransfer, + intentId = rendezvousKey, + amount = transfer.kin, + source = sourceCluster, + destination = currentTray.outgoing.getCluster().vaultPublicKey + ) + } + } + + val feePayments = mutableListOf() + + // Code Fee + if (fee > 0) { + feePayments.add( + ActionFeePayment.newInstance( + kind = ActionFeePayment.Kind.Code, + cluster = currentTray.outgoing.getCluster(), + amount = fee + ) + ) + } + + concreteFees.onEach { (feeAmount, destination) -> + feePayments.add( + ActionFeePayment.newInstance( + kind = ActionFeePayment.Kind.ThirdParty(destination), + cluster = currentTray.outgoing.getCluster(), + amount = feeAmount, + ) + ) + } + + // 2. Transfer all collected funds from the temp + // outgoing account to the destination account + + val outgoing = ActionWithdraw.newInstance( + kind = ActionWithdraw.Kind.NoPrivacyWithdraw(netAmount.kin), + cluster = currentTray.outgoing.getCluster(), + destination = destination, + metadata = metadata + ) + + // 3. Redistribute the funds to optimize for a + // subsequent payment out of the buckets + + val redistributes = currentTray.redistribute().map { exchange -> + ActionTransfer.newInstance( + kind = ActionTransfer.Kind.TempPrivacyExchange, + intentId = rendezvousKey, + amount = exchange.kin, + source = currentTray.cluster(exchange.from), + destination = currentTray.cluster(exchange.to!!).vaultPublicKey + // Exchanges always provide destination accounts + ) + } + + // 4. Rotate the outgoing account + + currentTray.incrementOutgoing() + val newOutgoing = currentTray.outgoing + + val rotation = listOf( + ActionOpenAccount.newInstance( + owner = organizer.tray.owner.getCluster().authority.keyPair.publicKeyBytes.toPublicKey(), + type = AccountType.Outgoing, + accountCluster = newOutgoing.getCluster() + ), + ) + + val endBalance = currentTray.availableBalance + + if (startBalance - endBalance != amount.kin) { + Timber.e( + "Expected: ${amount.kin}; actual = ${startBalance - endBalance}; " + + "difference: ${startBalance.quarks - currentTray.availableBalance.quarks - amount.kin.quarks}" + ) + throw IntentPrivateTransferException.BalanceMismatchException() + } + + val group = ActionGroup() + + group.actions += transfers + group.actions += listOf( + *feePayments.toTypedArray(), + outgoing, + *redistributes.toTypedArray(), + *rotation.toTypedArray() + ) + + return IntentPrivateTransfer( + id = rendezvousKey, + organizer = organizer, + destination = destination, + grossAmount = amount, + netAmount = netAmount, + fee = fee, + additionalFees = additionalFees, + isWithdrawal = isWithdrawal, + metadata = metadata, + actionGroup = group, + resultTray = currentTray, + ) + + } + } +} + +sealed class IntentPrivateTransferException: Exception() { + class BalanceMismatchException: IntentPrivateTransferException() + class InvalidFeeException: IntentPrivateTransferException() +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentPublicPayment.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentPublicPayment.kt new file mode 100644 index 000000000..979bf763f --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentPublicPayment.kt @@ -0,0 +1,98 @@ +package com.getcode.model.intents + +import com.codeinc.gen.transaction.v2.CodeTransactionService +import com.codeinc.gen.transaction.v2.CodeTransactionService.ExtendedPaymentMetadata +import com.getcode.model.KinAmount +import com.getcode.model.generate +import com.getcode.model.intents.actions.ActionTransfer +import com.getcode.network.repository.toSolanaAccount +import com.getcode.services.model.ExtendedMetadata +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.organizer.AccountCluster +import com.getcode.solana.organizer.AccountType +import com.getcode.solana.organizer.Organizer +import com.getcode.solana.organizer.Tray +import com.getcode.utils.toByteString + +class IntentPublicPayment( + override val id: PublicKey, + private val organizer: Organizer, + private val sourceCluster: AccountCluster, + private val destination: PublicKey, + private val amount: KinAmount, + val resultTray: Tray, + val metadata: ExtendedMetadata? = null, + + override val actionGroup: ActionGroup, +) : IntentType() { + override fun metadata(): CodeTransactionService.Metadata { + val pubPayBuilder = CodeTransactionService.SendPublicPaymentMetadata.newBuilder() + .setSource(sourceCluster.vaultPublicKey.bytes.toSolanaAccount()) + .setDestination(destination.bytes.toSolanaAccount()) + .setIsWithdrawal(true) + .setExchangeData( + CodeTransactionService.ExchangeData.newBuilder() + .setQuarks(amount.kin.quarks) + .setCurrency(amount.rate.currency.name.lowercase()) + .setExchangeRate(amount.rate.fx) + .setNativeAmount(amount.fiat) + ) + + if (metadata != null) { + when (metadata) { + is ExtendedMetadata.Any -> pubPayBuilder.setExtendedMetadata( + ExtendedPaymentMetadata.newBuilder() + .setValue(com.google.protobuf.Any.newBuilder() + .setTypeUrl(metadata.typeUrl) + .setValue(metadata.data.toByteString()))) + else -> Unit + } + } + + return CodeTransactionService.Metadata.newBuilder() + .setSendPublicPayment(pubPayBuilder.build()) + .build() + } + + companion object { + fun newInstance( + organizer: Organizer, + source: AccountType, + destination: PublicKey, + amount: KinAmount, + extendedMetadata: ExtendedMetadata? = null, + ): IntentPublicPayment { + val id = PublicKey.generate() + val currentTray = organizer.tray.copy() + val sourceCluster = organizer.tray.cluster(source) + + // 1. Transfer all funds in the primary account + // directly to the destination. This is a public + // transfer so no buckets involved and no rotation + // required. + + val transfer = ActionTransfer.newInstance( + kind = ActionTransfer.Kind.NoPrivacyTransfer, + intentId = id, + amount = amount.kin, + source = sourceCluster, + destination = destination + ) + + currentTray.decrement(source, kin = amount.kin) + + return IntentPublicPayment( + id = id, + organizer = organizer, + sourceCluster = sourceCluster, + destination = destination, + amount = amount, + actionGroup = ActionGroup().apply { + actions = listOf(transfer) + }, + resultTray = currentTray, + metadata = extendedMetadata + ) + } + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentPublicTransfer.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentPublicTransfer.kt new file mode 100644 index 000000000..02bcc27f7 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentPublicTransfer.kt @@ -0,0 +1,103 @@ +package com.getcode.model.intents + +import com.getcode.model.KinAmount +import com.getcode.model.generate +import com.getcode.model.intents.actions.ActionTransfer +import com.getcode.network.repository.toSolanaAccount +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.organizer.AccountCluster +import com.getcode.solana.organizer.AccountType +import com.getcode.solana.organizer.Organizer +import com.getcode.solana.organizer.Tray +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService + +class IntentPublicTransfer( + override val id: PublicKey, + private val organizer: Organizer, + private val sourceCluster: AccountCluster, + private val destination: PublicKey, + private val amount: KinAmount, + + val resultTray: Tray, + + override val actionGroup: ActionGroup, +) : IntentType() { + override fun metadata(): TransactionService.Metadata { + return TransactionService.Metadata.newBuilder() + .setSendPublicPayment( + TransactionService.SendPublicPaymentMetadata.newBuilder() + .setSource(sourceCluster.vaultPublicKey.bytes.toSolanaAccount()) + .setDestination(destination.bytes.toSolanaAccount()) + .setIsWithdrawal(true) + .setExchangeData( + TransactionService.ExchangeData.newBuilder() + .setQuarks(amount.kin.quarks) + .setCurrency(amount.rate.currency.name.lowercase()) + .setExchangeRate(amount.rate.fx) + .setNativeAmount(amount.fiat) + ) + ) + .build() + } + + sealed interface Destination { + data class Local(val accountType: AccountType): Destination + data class External(val publicKey: PublicKey): Destination + } + + companion object { + fun newInstance( + organizer: Organizer, + source: AccountType, + destination: Destination, + amount: KinAmount, + ): IntentPublicTransfer { + val id = PublicKey.generate() + val currentTray = organizer.tray.copy() + val sourceCluster = organizer.tray.cluster(source) + + + val target = when (destination) { + is Destination.External -> destination.publicKey + is Destination.Local -> organizer.tray.cluster(destination.accountType).vaultPublicKey + } + + // 1. Transfer all funds in the primary account + // directly to the destination. This is a public + // transfer so no buckets involved and no rotation + // required. + + val transfer = ActionTransfer.newInstance( + kind = ActionTransfer.Kind.NoPrivacyTransfer, + intentId = id, + amount = amount.kin, + source = sourceCluster, + destination = target + ) + + currentTray.decrement(source, kin = amount.kin) + + // If moving funds to an already known account + // we should update the balance accordingly + if (destination is Destination.Local) { + currentTray.increment(destination.accountType, amount.kin) + } + + return IntentPublicTransfer( + id = id, + organizer = organizer, + sourceCluster = sourceCluster, + destination = target, + amount = amount, + actionGroup = ActionGroup().apply { + actions = listOf(transfer) + }, + resultTray = currentTray, + ) + } + } +} + +sealed class IntentPublicTransferException: Exception() { + class BalanceMismatchException: IntentPublicTransferException() +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentReceive.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentReceive.kt new file mode 100644 index 000000000..e0f0fe846 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentReceive.kt @@ -0,0 +1,115 @@ +package com.getcode.model.intents + +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import com.getcode.model.Kin +import com.getcode.model.generate +import com.getcode.model.intents.actions.* +import com.getcode.model.toPublicKey +import com.getcode.network.repository.toSolanaAccount +import com.getcode.solana.organizer.AccountType +import com.getcode.solana.organizer.Organizer +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.organizer.Tray + +class IntentReceive( + override val id: PublicKey, + private val organizer: Organizer, + private val amount: Kin, + + val resultTray: Tray, + + override val actionGroup: ActionGroup, +) : IntentType() { + override fun metadata(): TransactionService.Metadata { + return TransactionService.Metadata.newBuilder() + .setReceivePaymentsPrivately( + TransactionService.ReceivePaymentsPrivatelyMetadata.newBuilder() + .setSource(organizer.tray.incoming.getCluster().vaultPublicKey.bytes.toSolanaAccount()) + .setQuarks(amount.quarks) + .setIsDeposit(false) + ) + .build() + } + + companion object { + fun newInstance( + organizer: Organizer, + amount: Kin + ): IntentReceive { + val intentId = PublicKey.generate() + val currentTray = organizer.tray.copy() + val startBalance = currentTray.availableBalance + + // 1. Move all funds from the incoming + // account to appropriate slots + + val transfers = currentTray.receive(AccountType.Incoming, amount = amount).map { transfer -> + ActionTransfer.newInstance( + kind = ActionTransfer.Kind.TempPrivacyTransfer, + intentId = intentId, + amount = transfer.kin, + source = currentTray.cluster(transfer.from), + destination = + currentTray.cluster(transfer.to!!).vaultPublicKey + ) + } + + // 2. Redistribute the funds to prepare for + // future transfers + + val redistributes = currentTray.redistribute().map { exchange -> + ActionTransfer.newInstance( + kind = ActionTransfer.Kind.TempPrivacyExchange, + intentId = intentId, + amount = exchange.kin, + source = currentTray.cluster(exchange.from), + destination = + currentTray.cluster(exchange.to!!).vaultPublicKey + // Exchanges always provide destination accounts + ) + } + + // 3. Rotate incoming account + + currentTray.incrementIncoming() + val newIncoming = currentTray.incoming + + val rotation = mutableListOf( + ActionOpenAccount.newInstance( + owner = organizer.tray.owner.getCluster().authority.keyPair.publicKeyBytes.toPublicKey(), + type = AccountType.Incoming, + accountCluster = newIncoming.getCluster() + ) + ) + + val endBalance = currentTray.availableBalance + + // We're just moving funds from incoming + // account to buckets, the balance + // shouldn't change + if (endBalance != startBalance) { + throw IntentReceiveException.BalanceMismatchException() + } + + val group = ActionGroup().apply { + actions = listOf( + *transfers.toTypedArray(), + *redistributes.toTypedArray(), + *rotation.toTypedArray() + ) + } + + return IntentReceive( + id = intentId, + organizer = organizer, + amount = amount, + actionGroup = group, + resultTray = currentTray, + ) + } + + sealed class IntentReceiveException : Exception() { + class BalanceMismatchException : IntentReceiveException() + } + } +} diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentRemoteReceive.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentRemoteReceive.kt new file mode 100644 index 000000000..286ea42d1 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentRemoteReceive.kt @@ -0,0 +1,78 @@ +package com.getcode.model.intents + +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import com.getcode.model.Kin +import com.getcode.model.generate +import com.getcode.model.intents.actions.ActionWithdraw +import com.getcode.network.repository.toSolanaAccount +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.organizer.AccountType +import com.getcode.solana.organizer.GiftCardAccount +import com.getcode.solana.organizer.Organizer +import com.getcode.solana.organizer.Tray + +class IntentRemoteReceive( + override val id: PublicKey, + private val organizer: Organizer, + private val giftCard: GiftCardAccount, + private val amount: Kin, + private val isVoidingGiftCard: Boolean, + + val resultTray: Tray, + + override val actionGroup: ActionGroup, +) : IntentType() { + override fun metadata(): TransactionService.Metadata { + return TransactionService.Metadata.newBuilder() + .setReceivePaymentsPublicly( + TransactionService.ReceivePaymentsPubliclyMetadata.newBuilder() + .setSource(giftCard.cluster.vaultPublicKey.bytes.toSolanaAccount()) + .setQuarks(amount.quarks) + .setIsRemoteSend(true) + .setIsIssuerVoidingGiftCard(isVoidingGiftCard) + ) + .build() + } + + companion object { + fun newInstance( + organizer: Organizer, + giftCard: GiftCardAccount, + amount: Kin, + isVoidingGiftCard: Boolean + ): IntentRemoteReceive { + val intentId = PublicKey.generate() + val currentTray = organizer.tray.copy() + val startBalance = currentTray.availableBalance + + val giftCardWithdraw = ActionWithdraw.newInstance( + kind = ActionWithdraw.Kind.NoPrivacyWithdraw(amount), + cluster = giftCard.cluster, + destination = organizer.incomingVault + ) + + currentTray.increment(AccountType.Incoming, amount) + + val endBalance = currentTray.availableBalance + if (endBalance - startBalance != amount) { + throw IntentRemoteReceiveException.BalanceMismatchException() + } + + return IntentRemoteReceive( + id = intentId, + organizer = organizer, + giftCard = giftCard, + amount = amount, + actionGroup = ActionGroup().apply { + actions = listOf(giftCardWithdraw) + }, + resultTray = currentTray, + isVoidingGiftCard = isVoidingGiftCard + ) + } + } + + sealed class IntentRemoteReceiveException : Exception() { + class BalanceMismatchException : IntentRemoteReceiveException() + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentRemoteSend.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentRemoteSend.kt new file mode 100644 index 000000000..5a49ab868 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentRemoteSend.kt @@ -0,0 +1,163 @@ +package com.getcode.model.intents + +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import com.getcode.model.KinAmount +import com.getcode.model.intents.actions.ActionOpenAccount +import com.getcode.model.intents.actions.ActionTransfer +import com.getcode.model.intents.actions.ActionWithdraw +import com.getcode.model.toPublicKey +import com.getcode.network.repository.toSolanaAccount +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.organizer.AccountType +import com.getcode.solana.organizer.GiftCardAccount +import com.getcode.solana.organizer.Organizer +import com.getcode.solana.organizer.Tray +import timber.log.Timber + +class IntentRemoteSend( + override val id: PublicKey, + private val organizer: Organizer, + private val giftCard: GiftCardAccount, + private val amount: KinAmount, + + val resultTray: Tray, + + override val actionGroup: ActionGroup, +) : IntentType() { + override fun metadata(): TransactionService.Metadata { + return TransactionService.Metadata.newBuilder() + .setSendPrivatePayment( + TransactionService.SendPrivatePaymentMetadata.newBuilder() + .setDestination(giftCard.cluster.vaultPublicKey.bytes.toSolanaAccount()) + .setIsWithdrawal(false) + .setIsRemoteSend(true) + .setExchangeData( + TransactionService.ExchangeData.newBuilder() + .setQuarks(amount.kin.quarks) + .setCurrency(amount.rate.currency.name.lowercase()) + .setExchangeRate(amount.rate.fx) + .setNativeAmount(amount.fiat) + ) + ) + .build() + } + + companion object { + fun newInstance( + rendezvousKey: PublicKey, + organizer: Organizer, + giftCard: GiftCardAccount, + amount: KinAmount + ): IntentRemoteSend { + val currentTray = organizer.tray.copy() + val startBalance = currentTray.availableBalance + + // 1. Open gift card account + + val openGiftCard = ActionOpenAccount.newInstance( + owner = giftCard.cluster.authority.keyPair.publicKeyBytes.toPublicKey(), + type = AccountType.RemoteSend, + accountCluster = giftCard.cluster + ) + + // 2. Move all funds from bucket accounts into the + // outgoing account and prepare to transfer + + val transfers = currentTray.transfer(amount = amount.kin).map { transfer -> + val sourceCluster = currentTray.cluster(transfer.from) + + // If the transfer is to another bucket, it's an internal + // exchange. Otherwise, it is considered a transfer. + if (transfer.to is AccountType.Bucket) { + ActionTransfer.newInstance( + kind = ActionTransfer.Kind.TempPrivacyExchange, + intentId = rendezvousKey, + amount = transfer.kin, + source = sourceCluster, + destination = currentTray.slot((transfer.to as AccountType.Bucket).type).getCluster().vaultPublicKey + ) + } else { + ActionTransfer.newInstance( + kind = ActionTransfer.Kind.TempPrivacyTransfer, + intentId = rendezvousKey, + amount = transfer.kin, + source = sourceCluster, + destination = currentTray.outgoing.getCluster().vaultPublicKey + ) + } + } + + // 3. Transfer all collected funds from the temp + // outgoing account to the destination account + + val outgoing = ActionWithdraw.newInstance( + kind = ActionWithdraw.Kind.NoPrivacyWithdraw(amount.kin), + cluster = currentTray.outgoing.getCluster(), + destination = giftCard.cluster.vaultPublicKey + ) + + // 4. Redistribute the funds to optimize for a + // subsequent payment out of the buckets + + val redistributes = currentTray.redistribute().map { exchange -> + ActionTransfer.newInstance( + kind = ActionTransfer.Kind.TempPrivacyExchange, + intentId = rendezvousKey, + amount = exchange.kin, + source = currentTray.cluster(exchange.from), + destination = currentTray.cluster(exchange.to!!).vaultPublicKey + // Exchanges always provide destination accounts + ) + } + + // 5. Rotate the outgoing account + + currentTray.incrementOutgoing() + val newOutgoing = currentTray.outgoing + + val rotation = listOf( + ActionOpenAccount.newInstance( + owner = currentTray.owner.getCluster().authority.keyPair.publicKeyBytes.toPublicKey(), + type = AccountType.Outgoing, + accountCluster = newOutgoing.getCluster() + ), + ) + + // 6. Close gift card account + + val endBalance = currentTray.availableBalance + + if (startBalance - endBalance != amount.kin) { + Timber.e( + "Expected: ${amount.kin}; actual = ${startBalance - endBalance}; " + + "difference: ${startBalance.quarks - currentTray.availableBalance.quarks - amount.kin.quarks}" + ) + throw IntentRemoteSendException.BalanceMismatchException() + } + + val group = ActionGroup().apply { + actions = listOf( + openGiftCard, + *transfers.toTypedArray(), + outgoing, + *redistributes.toTypedArray(), + *rotation.toTypedArray(), + ) + } + + return IntentRemoteSend( + id = rendezvousKey, + organizer = organizer, + giftCard = giftCard, + amount = amount, + actionGroup = group, + resultTray = currentTray, + ) + + } + } + + sealed class IntentRemoteSendException : Exception() { + class BalanceMismatchException : IntentRemoteSendException() + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentType.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentType.kt new file mode 100644 index 000000000..8aa5d1934 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentType.kt @@ -0,0 +1,99 @@ +package com.getcode.model.intents + +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import com.getcode.ed25519.Ed25519 +import com.getcode.model.intents.actions.ActionType +import com.getcode.model.intents.actions.numberActions +import com.getcode.network.repository.* +import com.getcode.solana.Message +import com.getcode.solana.SolanaTransaction +import com.getcode.solana.keys.Hash +import com.getcode.solana.keys.PublicKey +import com.getcode.utils.sign + +abstract class IntentType { + abstract val id: PublicKey + abstract val actionGroup: ActionGroup + + fun getActions() = actionGroup.actions + fun getAction(index: Int) = getActions()[index] + + fun apply(parameters: List) { + if (parameters.size != actionGroup.actions.size) { + throw Exception(Error.InvalidParameterCount.name) + } + + parameters.forEachIndexed { index, parameter -> + if (actionGroup.actions[index].id != parameter.actionId) { + throw Exception(Error.ActionParameterMismatch.name) + } + actionGroup.actions[index].serverParameter = parameter + } + } + + fun vixnHash(): Hash { + return actionGroup.actions.flatMap { it.compactMessages() } + .flatMap { it.toList() } + .take(32).let { Hash(it) } + } + + fun transaction(): SolanaTransaction { + val message = actionGroup.actions.flatMap { it.transactions() }.map { it.message } + .let { Message.newInstance(it.map { it.encode().toList() }.flatten()) }!! + val sigs = actionGroup.actions.flatMap { it.signatures() } + + return SolanaTransaction(message, sigs) + } + + fun signatures(): List = + actionGroup.actions.map { it.signatures().firstOrNull() }.mapNotNull { it } + + abstract fun metadata(): TransactionService.Metadata + + fun requestToSubmitSignatures(): TransactionService.SubmitIntentRequest { + return TransactionService.SubmitIntentRequest.newBuilder() + .setSubmitSignatures( + TransactionService.SubmitIntentRequest.SubmitSignatures.newBuilder() + .addAllSignatures(signatures().map { it.bytes.toByteArray().toSignature() }) + ) + .build() + } + + fun requestToSubmitActions(owner: Ed25519.KeyPair, deviceToken: String? = null): TransactionService.SubmitIntentRequest { + val submitActionsBuilder = TransactionService.SubmitIntentRequest.SubmitActions.newBuilder() + submitActionsBuilder.owner = owner.publicKeyBytes.toSolanaAccount() + submitActionsBuilder.id = id.toIntentId() + submitActionsBuilder.metadata = metadata() + submitActionsBuilder.addAllActions(actionGroup.actions.map { it.action() }) + + submitActionsBuilder.signature = submitActionsBuilder.sign(owner) + + return TransactionService.SubmitIntentRequest.newBuilder() + .setSubmitActions(submitActionsBuilder) + .build() + } + + enum class Error { + InvalidParameterCount, + ActionParameterMismatch + } +} + +class ActionGroup { + var actions: List = listOf() + set(value) { + field = value.numberActions() + } +} + +sealed interface CompactMessageArgs { + + data class Transfer( + val source: PublicKey, + val destination: PublicKey, + val amountInQuarks: Long, + val nonce: PublicKey, + val nonceValue: Hash, + ): CompactMessageArgs +} +typealias CompactMessage = ByteArray \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentUpgradePrivacy.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentUpgradePrivacy.kt new file mode 100644 index 000000000..9dbaa8367 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/IntentUpgradePrivacy.kt @@ -0,0 +1,149 @@ +package com.getcode.model.intents + +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import com.getcode.crypt.MnemonicPhrase +import com.getcode.model.Kin +import com.getcode.model.UpgradeableIntent +import com.getcode.model.extensions.newInstance +import com.getcode.model.intents.actions.ActionPrivacyUpgrade +import com.getcode.model.intents.actions.ActionTransfer +import com.getcode.solana.SolanaTransaction +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.Signature +import com.getcode.solana.keys.SplitterCommitmentAccounts +import com.getcode.solana.organizer.AccountCluster + +class IntentUpgradePrivacy( + override val id: PublicKey, + override val actionGroup: ActionGroup, +) : IntentType() { + override fun metadata(): TransactionService.Metadata { + return TransactionService.Metadata.newBuilder() + .setUpgradePrivacy(TransactionService.UpgradePrivacyMetadata.getDefaultInstance()) + .build() + } + + companion object { + fun newInstance( + mnemonic: MnemonicPhrase, + upgradeableIntent: UpgradeableIntent + ): IntentUpgradePrivacy { + val actionsMapped = upgradeableIntent.actions.map { upgradeableAction -> + val actionAmount = upgradeableAction.originalAmount + val originalDestination = upgradeableAction.originalDestination + val treasury = upgradeableAction.treasury + val recentRoot = upgradeableAction.recentRoot + val originalNonce = upgradeableAction.originalNonce + val originalRecentBlockhash = upgradeableAction.originalRecentBlockhash + + val sourceCluster = AccountCluster.using( + type = upgradeableAction.sourceAccountType, + index = upgradeableAction.sourceDerivationIndex.toInt(), + mnemonic = mnemonic + ) + + // Validate the server isn't malicious and is providing + // the original details of the transaction + validate( + transactionData = upgradeableAction.transactionBlob, + clientSignature = upgradeableAction.clientSignature, + intentId = upgradeableIntent.id, + actionId = upgradeableAction.id, + amount = actionAmount, + source = sourceCluster, + destination = originalDestination, + originalNonce = originalNonce, + treasury = treasury, + recentRoot = recentRoot + ) + + // We have to derive the original commitment accounts because + // we'll need to verify whether the commitment state account + // is part of the merkle tree provided by server paramaeters + val originalSplitterAccounts = SplitterCommitmentAccounts.newInstance( + source = sourceCluster, + destination = originalDestination, + amount = actionAmount, + treasury = treasury, + recentRoot = recentRoot, + intentId = upgradeableIntent.id, + actionId = upgradeableAction.id + ) + + ActionPrivacyUpgrade.newInstance( + source = sourceCluster, + originalActionID = upgradeableAction.id, + originalCommitmentStateAccount = originalSplitterAccounts.state.publicKey, + originalAmount = actionAmount, + originalNonce = originalNonce, + originalRecentBlockhash = originalRecentBlockhash, + treasury = treasury + ) + } + + return IntentUpgradePrivacy( + id = upgradeableIntent.id, + actionGroup = ActionGroup().apply { this.actions = actionsMapped } + ) + } + + + fun validate( + transactionData: List, + clientSignature: Signature, + intentId: PublicKey, + actionId: Int, + amount: Kin, + source: AccountCluster, + destination: PublicKey, + originalNonce: PublicKey, + treasury: PublicKey, + recentRoot: com.getcode.solana.keys.Hash + ) { + val transaction = SolanaTransaction.fromList(transactionData) + ?: throw IntentUpgradePrivacyException.FailedToParseTransactionException() + + val originalTransfer = ActionTransfer.newInstance( + kind = ActionTransfer.Kind.TempPrivacyTransfer, + intentId = intentId, + amount = amount, + source = source, + destination = destination + ) + + originalTransfer.id = actionId + originalTransfer.serverParameter = ServerParameter( + actionId = actionId, + parameter = ServerParameter.Parameter.TempPrivacy( + treasury = treasury, + recentRoot = recentRoot + ), + configs = listOf( + ServerParameter.Config( + nonce = originalNonce, + blockhash = transaction.recentBlockhash + ) + ), + ) + + val originalTransaction = originalTransfer.transactions()[0] + + if (originalTransaction.encode() != transactionData) { + throw IntentUpgradePrivacyException.TransactionMismatchException() + } + + // (Optional) Reach into transaction and make sure the source is the same + val signature = originalTransaction.sign(source.authority.keyPair).firstOrNull() + + if (signature != clientSignature) { + throw IntentUpgradePrivacyException.SignatureMismatchException() + } + } + } +} + +sealed class IntentUpgradePrivacyException : Exception() { + class FailedToParseTransactionException : IntentUpgradePrivacyException() + class TransactionMismatchException : IntentUpgradePrivacyException() + class SignatureMismatchException : IntentUpgradePrivacyException() +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/ServerParameter.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/ServerParameter.kt new file mode 100644 index 000000000..806df60a2 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/ServerParameter.kt @@ -0,0 +1,116 @@ +package com.getcode.model.intents + +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import com.getcode.model.Kin +import com.getcode.model.toHash +import com.getcode.model.toPublicKey +import com.getcode.solana.keys.Hash +import com.getcode.solana.keys.PublicKey + +class ServerParameter( + val actionId: Int, + val parameter: Parameter?, + val configs: List +) { + data class Config(val nonce: PublicKey, val blockhash: Hash) + + sealed class Parameter { + data class TempPrivacy(val treasury: PublicKey, val recentRoot: Hash): Parameter() + data class PermanentPrivacyUpgrade( + val newCommitment: PublicKey, + val newCommitmentTranscript: Hash, + val newCommitmentDestination: PublicKey, + val newCommitmentAmount: Kin, + val merkleRoot: Hash, + val merkleProof: List, + ): Parameter() + + data class FeePayment(val publicKey: PublicKey): Parameter() + + companion object { + fun newInstance(proto: TransactionService.ServerParameter): Parameter? { + return when (proto.typeCase) { + TransactionService.ServerParameter.TypeCase.TEMPORARY_PRIVACY_TRANSFER -> { + val param = proto.temporaryPrivacyTransfer + val treasury = PublicKey( + param.treasury.value.toByteArray().toList() + ) + val recentRoot = Hash( + param.recentRoot.value.toByteArray().toList() + ) + return TempPrivacy(treasury, recentRoot) + } + TransactionService.ServerParameter.TypeCase.TEMPORARY_PRIVACY_EXCHANGE -> { + val param = proto.temporaryPrivacyExchange + val treasury = PublicKey( + param.treasury.value.toByteArray().toList() + ) + val recentRoot = Hash( + param.recentRoot.value.toByteArray().toList() + ) + return TempPrivacy(treasury, recentRoot) + } + TransactionService.ServerParameter.TypeCase.PERMANENT_PRIVACY_UPGRADE -> { + val param = proto.permanentPrivacyUpgrade + val newCommitment = PublicKey( + param.newCommitment.value.toByteArray().toList() + ) + val newCommitmentTranscript = Hash( + param.newCommitmentTranscript.value.toByteArray().toList() + ) + val newCommitmentDestination = PublicKey( + param.newCommitmentDestination.value.toByteArray().toList() + ) + val merkleRoot = Hash( + param.merkleRoot.value.toByteArray().toList() + ) + + val merkleProof = param.merkleProofList.map { + Hash(it.value.toByteArray().toList()) + } + + PermanentPrivacyUpgrade( + newCommitment = newCommitment, + newCommitmentTranscript = newCommitmentTranscript, + newCommitmentDestination = newCommitmentDestination, + newCommitmentAmount = Kin.fromQuarks(quarks = param.newCommitmentAmount), + merkleRoot = merkleRoot, + merkleProof = merkleProof + ) + } + TransactionService.ServerParameter.TypeCase.FEE_PAYMENT -> { + val param = proto.feePayment + + // PublicKey will be `nil` for .thirdParty fee payments + val optionalDestination = PublicKey( + param.codeDestination.value.toByteArray().toList() + ) + FeePayment(optionalDestination) + } + TransactionService.ServerParameter.TypeCase.OPEN_ACCOUNT, + TransactionService.ServerParameter.TypeCase.NO_PRIVACY_WITHDRAW, + TransactionService.ServerParameter.TypeCase.TYPE_NOT_SET, + TransactionService.ServerParameter.TypeCase.NO_PRIVACY_TRANSFER -> null + } + } + + } + } + + companion object { + fun newInstance(proto: TransactionService.ServerParameter): ServerParameter { + return ServerParameter( + actionId = proto.actionId, + parameter = Parameter.newInstance(proto), + configs = proto.noncesList.map { + Config( + nonce = it.nonce.value.toByteArray().toPublicKey(), + blockhash = it.blockhash.value.toByteArray().toHash() + ) + } + ) + } + } +} + + diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/SwapIntent.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/SwapIntent.kt new file mode 100644 index 000000000..68d1068f6 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/SwapIntent.kt @@ -0,0 +1,59 @@ +package com.getcode.model.intents + +import com.codeinc.gen.transaction.v2.CodeTransactionService.SwapRequest +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.model.generate +import com.getcode.network.repository.toSignature +import com.getcode.solana.SolanaTransaction +import com.getcode.solana.builder.TransactionBuilder +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.Signature +import com.getcode.solana.organizer.AccountCluster +import com.getcode.solana.organizer.AccountType +import com.getcode.solana.organizer.Organizer +import java.lang.IllegalStateException + +class SwapIntent( + val id: PublicKey, + val organizer: Organizer, + val owner: KeyPair, + val swapCluster: AccountCluster, +) { + + var parameters: SwapConfigParameters? = null + + fun sign(parameters: SwapConfigParameters): List { + val transaction = transaction(parameters) + return transaction.sign(organizer.swapKeyPair) + } + + fun transaction(parameters: SwapConfigParameters): SolanaTransaction { + return TransactionBuilder.swap( + fromUsdc = swapCluster, + toPrimary = organizer.primaryVault, + parameters = parameters + ) + } + + companion object { + fun newInstance(organizer: Organizer): SwapIntent { + return SwapIntent( + id = PublicKey.generate(), + organizer = organizer, + owner = organizer.ownerKeyPair, + swapCluster = organizer.tray.cluster(AccountType.Swap), + ) + } + } +} + +fun SwapIntent.requestToSubmitSignatures(): SwapRequest? = runCatching { + parameters ?: throw IllegalStateException("Missing swap parameters") + + return@runCatching SwapRequest.newBuilder() + .setSubmitSignature( + SwapRequest.SubmitSignature.newBuilder() + .setSignature(sign(parameters!!).first().bytes.toByteArray().toSignature()) + .build() + ).build() +}.getOrNull() \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/actions/ActionFeePayment.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/actions/ActionFeePayment.kt new file mode 100644 index 000000000..7760e229b --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/actions/ActionFeePayment.kt @@ -0,0 +1,86 @@ +package com.getcode.model.intents.actions + +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import com.codeinc.gen.transaction.v2.CodeTransactionService.FeePaymentAction +import com.getcode.ed25519.Ed25519 +import com.getcode.model.Kin +import com.getcode.model.intents.ServerParameter +import com.getcode.network.repository.toSolanaAccount +import com.getcode.solana.SolanaTransaction +import com.getcode.solana.builder.TransactionBuilder +import com.getcode.solana.organizer.AccountCluster + +class ActionFeePayment( + override var id: Int, + override var serverParameter: ServerParameter? = null, + override val signer: Ed25519.KeyPair? = null, + val kind: Kind, + val cluster: AccountCluster, + val amount: Kin, + val configCountRequirement: Int = 1, +): ActionType() { + + sealed interface Kind { + val codeType: Int + data object Code: Kind { + override val codeType: Int = 0 + } + data class ThirdParty(val destination: com.getcode.solana.keys.PublicKey): Kind { + override val codeType: Int = 1 + } + } + + override fun transactions(): List { + val configs = serverParameter?.configs ?: return emptyList() + + val timelock = cluster.timelock ?: return emptyList() + + val destination: com.getcode.solana.keys.PublicKey = when (kind) { + Kind.Code -> { + (serverParameter?.parameter as? ServerParameter.Parameter.FeePayment)?.publicKey ?: return emptyList() + } + is Kind.ThirdParty -> kind.destination + } + + return configs.map { config -> + TransactionBuilder.transfer( + timelockDerivedAccounts = timelock, + destination = destination, + amount = amount, + nonce = config.nonce, + recentBlockhash = config.blockhash, + kreIndex = kreIndex + ) + } + } + + override fun action(): TransactionService.Action { + return TransactionService.Action.newBuilder() + .setId(id) + .setFeePayment( + FeePaymentAction.newBuilder() + .setTypeValue(kind.codeType) + .setAuthority(cluster.authority.keyPair.publicKeyBytes.toSolanaAccount()) + .setSource(cluster.vaultPublicKey.bytes.toSolanaAccount()) + .setAmount(amount.quarks) + .apply { + if (kind is Kind.ThirdParty) { + setDestination(kind.destination.bytes.toSolanaAccount()) + } + } + .build() + ).build() + } + + companion object { + fun newInstance(kind: Kind, cluster: AccountCluster, amount: Kin): ActionFeePayment { + return ActionFeePayment( + id = 0, + signer = cluster.authority.keyPair, + cluster = cluster, + kind = kind, + amount = amount + ) + } + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/actions/ActionOpenAccount.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/actions/ActionOpenAccount.kt new file mode 100644 index 000000000..49704d19a --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/actions/ActionOpenAccount.kt @@ -0,0 +1,63 @@ +package com.getcode.model.intents.actions + +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import com.getcode.ed25519.Ed25519 +import com.getcode.model.intents.ServerParameter +import com.getcode.network.repository.toSolanaAccount +import com.getcode.solana.SolanaTransaction +import com.getcode.solana.organizer.AccountCluster +import com.getcode.solana.organizer.AccountType +import com.getcode.utils.sign + +class ActionOpenAccount( + override var id: Int, + override var serverParameter: ServerParameter? = null, + override val signer: Ed25519.KeyPair?, + val owner: com.getcode.solana.keys.PublicKey, + val type: AccountType, + val accountCluster: AccountCluster +) : ActionType() { + //static let configCountRequirement: Int = 0 + + override fun transactions(): List = listOf() + + override fun action(): TransactionService.Action { + return TransactionService.Action.newBuilder() + .apply { + this.id = + this@ActionOpenAccount.id + this.openAccount = TransactionService.OpenAccountAction.newBuilder().apply { + this.index = + this@ActionOpenAccount.accountCluster.index.toLong() + this.owner = + this@ActionOpenAccount.owner.bytes.toSolanaAccount() + this.accountType = + this@ActionOpenAccount.type.getAccountType() + this.authority = + this@ActionOpenAccount.accountCluster.authority.keyPair.publicKeyBytes.toSolanaAccount() + this.token = + this@ActionOpenAccount.accountCluster.vaultPublicKey + .bytes.toSolanaAccount() + this.authoritySignature = + this.sign(accountCluster.authority.keyPair) + }.build() + } + .build() + } + + companion object { + fun newInstance( + owner: com.getcode.solana.keys.PublicKey, + type: AccountType, + accountCluster: AccountCluster + ): ActionOpenAccount { + return ActionOpenAccount( + id = 0, + owner = owner, + type = type, + accountCluster = accountCluster, + signer = null + ) + } + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/actions/ActionPrivacyUpgrade.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/actions/ActionPrivacyUpgrade.kt new file mode 100644 index 000000000..1aea727f7 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/actions/ActionPrivacyUpgrade.kt @@ -0,0 +1,127 @@ +package com.getcode.model.intents.actions + +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import com.getcode.ed25519.Ed25519 +import com.getcode.model.Kin +import com.getcode.model.extensions.newInstance +import com.getcode.model.intents.ServerParameter +import com.getcode.solana.SolanaTransaction +import com.getcode.solana.builder.TransactionBuilder +import com.getcode.solana.keys.verifyContained +import com.getcode.solana.organizer.AccountCluster +import timber.log.Timber + +class ActionPrivacyUpgrade( + override var id: Int, + override var serverParameter: ServerParameter? = null, + override val signer: Ed25519.KeyPair?, + + var source: AccountCluster, + var originalActionID: Int, + var originalCommitmentStateAccount: com.getcode.solana.keys.PublicKey, + var originalAmount: Kin, + var originalNonce: com.getcode.solana.keys.PublicKey, + var originalRecentBlockhash: com.getcode.solana.keys.Hash, + var treasury: com.getcode.solana.keys.PublicKey +) : ActionType() { + val configCountRequirement: Int = 1 + + override fun transactions(): List { + serverParameter ?: throw ActionPrivacyUpgradeException.MissingServerParameterException() + + val privacyUpgrade = serverParameter?.parameter + if (privacyUpgrade !is ServerParameter.Parameter.PermanentPrivacyUpgrade) { + throw ActionPrivacyUpgradeException.MissingPrivacyUpgradeParameterException() + } + + /// Validate the merkle proof and ensure that the original commitment + /// accounts exist in the merkle tree provided by the server via the + /// `merkleRoot` and `merkleProof` params + + val leaf = originalCommitmentStateAccount + + val isProofValid = leaf.verifyContained( + privacyUpgrade.merkleRoot, + privacyUpgrade.merkleProof + ) + + Timber.i("isProofValid: $isProofValid") + + if (!isProofValid) { + throw ActionPrivacyUpgradeException.InvalidMerkleProofException() + } + + val timelock = source.timelock ?: throw ActionPrivacyUpgradeException.InvalidSourceException() + + // Server may provide the nonce and recentBlockhash and + // it may match the original but we shouldn't trust it. + // We'll user the original nonce and recentBlockhash that + // the original transaction used. + + val splitterAccounts = com.getcode.solana.keys.SplitterCommitmentAccounts.newInstance( + treasury = treasury, + destination = privacyUpgrade.newCommitmentDestination, + recentRoot = privacyUpgrade.merkleRoot, + transcript = privacyUpgrade.newCommitmentTranscript, + amount = privacyUpgrade.newCommitmentAmount + ) + + val transaction = TransactionBuilder.transfer( + timelockDerivedAccounts = timelock, + destination = splitterAccounts.vault.publicKey, + amount = originalAmount, + nonce = originalNonce, + recentBlockhash = originalRecentBlockhash, + kreIndex = kreIndex + ) + + return listOf(transaction) + + } + + override fun action(): TransactionService.Action { + return TransactionService.Action.newBuilder() + .apply { + this.id = + this@ActionPrivacyUpgrade.id + this.permanentPrivacyUpgrade = + TransactionService.PermanentPrivacyUpgradeAction.newBuilder().apply { + this.actionId = + this@ActionPrivacyUpgrade.originalActionID + }.build() + } + .build() + } + + companion object { + fun newInstance( + source: AccountCluster, + originalActionID: Int, + originalCommitmentStateAccount: com.getcode.solana.keys.PublicKey, + originalAmount: Kin, + originalNonce: com.getcode.solana.keys.PublicKey, + originalRecentBlockhash: com.getcode.solana.keys.Hash, + treasury: com.getcode.solana.keys.PublicKey + ): ActionPrivacyUpgrade { + return ActionPrivacyUpgrade( + id = 0, + signer = source.authority.keyPair, + source = source, + + originalActionID = originalActionID, + originalCommitmentStateAccount = originalCommitmentStateAccount, + originalAmount = originalAmount, + originalNonce = originalNonce, + originalRecentBlockhash = originalRecentBlockhash, + treasury = treasury + ) + } + } +} + +sealed class ActionPrivacyUpgradeException : Exception() { + class MissingServerParameterException : ActionPrivacyUpgradeException() + class MissingPrivacyUpgradeParameterException : ActionPrivacyUpgradeException() + class InvalidMerkleProofException : ActionPrivacyUpgradeException() + class InvalidSourceException: ActionPrivacyUpgradeException() +} diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/actions/ActionTransfer.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/actions/ActionTransfer.kt new file mode 100644 index 000000000..8a5562741 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/actions/ActionTransfer.kt @@ -0,0 +1,160 @@ +package com.getcode.model.intents.actions + +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import com.getcode.ed25519.Ed25519 +import com.getcode.model.Kin +import com.getcode.model.extensions.newInstance +import com.getcode.model.intents.CompactMessageArgs +import com.getcode.model.intents.ServerParameter +import com.getcode.model.intents.actions.ActionTransfer.Kind.* +import com.getcode.network.repository.toSolanaAccount +import com.getcode.solana.SolanaTransaction +import com.getcode.solana.builder.TransactionBuilder +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.organizer.AccountCluster + +class ActionTransfer( + override var id: Int, + override var serverParameter: ServerParameter? = null, + override val signer: Ed25519.KeyPair? = null, + + val kind: Kind, + val intentId: PublicKey, + val amount: Kin, + val source: AccountCluster, + val destination: PublicKey, +) : ActionType() { + + override fun transactions(): List { + val serverParameter = serverParameter ?: return emptyList() + val timelock = source.timelock ?: return emptyList() + + val tempPrivacyParameter = serverParameter.parameter + + val resolvedDestination: PublicKey = if (tempPrivacyParameter is ServerParameter.Parameter.TempPrivacy) { + val splitterAccounts = com.getcode.solana.keys.SplitterCommitmentAccounts.newInstance( + source = source, + destination = destination, + amount = amount, + treasury = tempPrivacyParameter.treasury, + recentRoot = tempPrivacyParameter.recentRoot, + intentId = intentId, + actionId = id + ) + + splitterAccounts.vault.publicKey + } else { + destination + } + + return serverParameter.configs.map { config -> + TransactionBuilder.transfer( + timelockDerivedAccounts = timelock, + destination = resolvedDestination, + amount = amount, + nonce = config.nonce, + recentBlockhash = config.blockhash, + kreIndex = kreIndex + ) + } + } + + override fun compactMessageArgs(): List { + val configs = serverParameter?.configs ?: return emptyList() + return configs.map { + + val amountInQuarks = amount.quarks + val nonceAccount = it.nonce + val nonceValue = it.blockhash + + CompactMessageArgs.Transfer( + source = source.vaultPublicKey, + destination = destination, + amountInQuarks = amountInQuarks, + nonce = nonceAccount, + nonceValue = nonceValue + ) + } + } + + + override fun action(): TransactionService.Action { + return TransactionService.Action.newBuilder() + .apply { + this.id = + this@ActionTransfer.id + + when (kind) { + TempPrivacyTransfer -> { + this.temporaryPrivacyTransfer = + TransactionService.TemporaryPrivacyTransferAction.newBuilder().apply { + this.source = + this@ActionTransfer.source.vaultPublicKey.bytes.toSolanaAccount() + this.destination = + this@ActionTransfer.destination.bytes.toSolanaAccount() + this.authority = + this@ActionTransfer.source.authority.keyPair.publicKeyBytes.toSolanaAccount() + this.amount = + this@ActionTransfer.amount.quarks + }.build() + } + TempPrivacyExchange -> { + this.temporaryPrivacyExchange = + TransactionService.TemporaryPrivacyExchangeAction.newBuilder().apply { + this.source = + this@ActionTransfer.source.vaultPublicKey.bytes.toSolanaAccount() + this.destination = + this@ActionTransfer.destination.bytes.toSolanaAccount() + this.authority = + this@ActionTransfer.source.authority.keyPair.publicKeyBytes.toSolanaAccount() + this.amount = + this@ActionTransfer.amount.quarks + }.build() + } + NoPrivacyTransfer -> { + this.noPrivacyTransfer = + TransactionService.NoPrivacyTransferAction.newBuilder().apply { + this.source = + this@ActionTransfer.source.vaultPublicKey.bytes.toSolanaAccount() + this.destination = + this@ActionTransfer.destination.bytes.toSolanaAccount() + this.authority = + this@ActionTransfer.source.authority.keyPair.publicKeyBytes.toSolanaAccount() + this.amount = + this@ActionTransfer.amount.quarks + }.build() + } + } + }.build() + + + } + + companion object { + fun newInstance( + kind: Kind, + intentId: PublicKey, + amount: Kin, + source: AccountCluster, + destination: PublicKey + ): ActionTransfer { + return ActionTransfer( + id = 0, + signer = source.authority.keyPair, + kind = kind, + intentId = intentId, + amount = amount, + source = source, + destination = destination + ) + } + + const val configCountRequirement: Int = 1 + } + + enum class Kind { + TempPrivacyTransfer, + TempPrivacyExchange, + NoPrivacyTransfer, + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/actions/ActionType.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/actions/ActionType.kt new file mode 100644 index 000000000..8f3d28268 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/actions/ActionType.kt @@ -0,0 +1,59 @@ +package com.getcode.model.intents.actions + +import com.getcode.crypt.Sha256Hash +import com.getcode.ed25519.Ed25519 +import com.getcode.model.intents.CompactMessage +import com.getcode.model.intents.CompactMessageArgs +import com.getcode.model.intents.ServerParameter +import com.getcode.solana.SolanaTransaction +import com.getcode.utils.toByteArray +import org.kin.sdk.base.models.toUTF8Bytes +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService + +abstract class ActionType { + abstract var id: Int + abstract var serverParameter: ServerParameter? + abstract val signer: Ed25519.KeyPair? + + //abstract var configCountRequirement: Int + + abstract fun transactions(): List + open fun compactMessageArgs(): List = emptyList() + fun compactMessages(): List { + return compactMessageArgs().map { args -> + when (args) { + is CompactMessageArgs.Transfer -> { + + val data = mutableListOf() + data.addAll("transfer".toUTF8Bytes().toList()) + data.addAll(args.source.bytes) + data.addAll(args.destination.bytes) + data.addAll(args.amountInQuarks.toByteArray().toList()) + data.addAll(args.nonce.bytes) + data.addAll(args.nonceValue.bytes) + + Sha256Hash.hash(data.toByteArray()) + } + } + } + } + + fun signatures(): List { + return signer?.let { s -> + compactMessages().map { + com.getcode.solana.keys.Signature(Ed25519.sign(it, s).toList()) } + }.orEmpty() + } + + abstract fun action(): TransactionService.Action + + companion object { + const val kreIndex: Int = 268 + } +} + +fun List.numberActions(): List { + return this.mapIndexed { index, _ -> + this[index].apply { this.id = index } + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/actions/ActionWithdraw.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/actions/ActionWithdraw.kt new file mode 100644 index 000000000..9843c7679 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/intents/actions/ActionWithdraw.kt @@ -0,0 +1,95 @@ +package com.getcode.model.intents.actions + +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import com.getcode.ed25519.Ed25519 +import com.getcode.model.Kin +import com.getcode.model.intents.ServerParameter +import com.getcode.model.toPublicKey +import com.getcode.network.repository.toSolanaAccount +import com.getcode.services.model.ExtendedMetadata +import com.getcode.solana.SolanaTransaction +import com.getcode.solana.builder.TransactionBuilder +import com.getcode.solana.organizer.AccountCluster + +class ActionWithdraw( + override var id: Int, + override var serverParameter: ServerParameter? = null, + override val signer: Ed25519.KeyPair?, + + val kind: Kind, + + val cluster: AccountCluster, + val destination: com.getcode.solana.keys.PublicKey, + val legacy: Boolean, + val metadata: ExtendedMetadata? = null, +) : ActionType() { + + override fun transactions(): List { + val timelock = cluster.timelock ?: return emptyList() + return serverParameter?.configs?.map { config -> + TransactionBuilder.closeDormantAccount( + authority = cluster.authority.keyPair.publicKeyBytes.toPublicKey(), + timelockDerivedAccounts = timelock, + destination = destination, + nonce = config.nonce, + recentBlockhash = config.blockhash, + kreIndex = kreIndex, + legacy = legacy, + metadata = metadata, + ) + }.orEmpty() + } + + override fun action(): TransactionService.Action { + return TransactionService.Action.newBuilder() + .apply { + this.id = + this@ActionWithdraw.id + + when (kind) { + is Kind.NoPrivacyWithdraw -> { + this.noPrivacyWithdraw = + TransactionService.NoPrivacyWithdrawAction.newBuilder().apply { + this.authority = + this@ActionWithdraw.cluster.authority.keyPair.publicKeyBytes.toSolanaAccount() + this.source = + this@ActionWithdraw.cluster.vaultPublicKey.bytes.toSolanaAccount() + this.destination = + this@ActionWithdraw.destination.bytes.toSolanaAccount() + this.amount = + this@ActionWithdraw.kind.amount.quarks + this.shouldClose = + true + }.build() + } + } + }.build() + } + + companion object { + fun newInstance( + kind: Kind, + cluster: AccountCluster, + destination: com.getcode.solana.keys.PublicKey, + legacy: Boolean = false, + metadata: ExtendedMetadata? = null, + ): ActionWithdraw { + return ActionWithdraw( + id = 0, + signer = cluster.authority.keyPair, + + kind = kind, + cluster = cluster, + destination = destination, + legacy = legacy, + metadata = metadata + ) + } + + const val configCountRequirement: Int = 1 + } + + sealed class Kind { + data class NoPrivacyWithdraw(val amount: Kin): Kind() + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/protomapping/Platform.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/protomapping/Platform.kt new file mode 100644 index 000000000..5fa7b3c80 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/protomapping/Platform.kt @@ -0,0 +1,10 @@ +package com.getcode.model.protomapping + +import com.codeinc.gen.chat.v2.ChatService +import com.getcode.model.chat.Platform +import com.getcode.model.chat.Platform.Unknown +import com.getcode.model.chat.Platform.entries + +operator fun Platform.Companion.invoke(proto: ChatService.Platform): Platform { + return runCatching { entries[proto.ordinal] }.getOrNull() ?: Unknown +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/protomapping/Pointer.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/protomapping/Pointer.kt new file mode 100644 index 000000000..d78cd17eb --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/protomapping/Pointer.kt @@ -0,0 +1,19 @@ +package com.getcode.model.protomapping + +import com.codeinc.gen.chat.v2.ChatService +import com.getcode.model.chat.Pointer +import com.getcode.model.uuid + +operator fun Pointer.Companion.invoke(proto: ChatService.Pointer): Pointer { + val memberId = proto.memberId.value.toList() + val messageId = proto.value.value.toList().uuid ?: return Pointer.Unknown(memberId) + + return when (proto.type) { + ChatService.PointerType.UNKNOWN_POINTER_TYPE -> Pointer.Unknown(proto.memberId.value.toList()) + ChatService.PointerType.READ -> Pointer.Read(memberId, messageId) + ChatService.PointerType.DELIVERED -> Pointer.Delivered(memberId, messageId) + ChatService.PointerType.SENT -> Pointer.Sent(memberId, messageId) + ChatService.PointerType.UNRECOGNIZED -> Pointer.Unknown(memberId) + else -> Pointer.Unknown(memberId) + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/protomapping/Reference.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/protomapping/Reference.kt new file mode 100644 index 000000000..ccb8faefc --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/protomapping/Reference.kt @@ -0,0 +1,17 @@ +package com.getcode.model.protomapping + +import com.codeinc.gen.chat.v2.ChatService.ExchangeDataContent +import com.codeinc.gen.chat.v2.ChatService.ExchangeDataContent.ReferenceCase +import com.getcode.model.chat.Reference +import com.getcode.model.chat.Reference.IntentId +import com.getcode.model.chat.Reference.NoneSet +import com.getcode.model.chat.Reference.Signature + +operator fun Reference.Companion.invoke(proto: ExchangeDataContent): Reference { + return when (proto.referenceCase) { + ReferenceCase.INTENT -> IntentId(proto.intent.value.toByteArray().toList()) + ReferenceCase.SIGNATURE -> Signature(proto.signature.value.toByteArray().toList()) + ReferenceCase.REFERENCE_NOT_SET -> NoneSet + null -> NoneSet + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/protomapping/TwitterUser.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/protomapping/TwitterUser.kt new file mode 100644 index 000000000..af654cb05 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/protomapping/TwitterUser.kt @@ -0,0 +1,26 @@ +package com.getcode.model.protomapping + +import com.codeinc.gen.user.v1.CodeIdentityService as IdentityService +import com.getcode.model.CurrencyCode +import com.getcode.model.Fiat +import com.getcode.model.TwitterUser +import com.getcode.model.TwitterUser.VerificationStatus +import com.getcode.solana.keys.PublicKey + +operator fun TwitterUser.Companion.invoke(proto: IdentityService.TwitterUser): TwitterUser? { + val avatarUrl = proto.profilePicUrl + + val tipAddress = runCatching { PublicKey.fromByteString(proto.tipAddress.value) }.getOrNull() ?: return null + + return TwitterUser( + username = proto.username, + displayName = proto.name, + imageUrl = avatarUrl, + followerCount = proto.followerCount, + tipAddress = tipAddress, + verificationStatus = VerificationStatus.entries.getOrNull(proto.verifiedTypeValue) ?: VerificationStatus.unknown, + costOfFriendship = Fiat(currency = CurrencyCode.USD, amount = 1.00), + isFriend = false, + chatId = emptyList() + ) +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/model/protomapping/Verb.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/model/protomapping/Verb.kt new file mode 100644 index 000000000..be40723fa --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/model/protomapping/Verb.kt @@ -0,0 +1,34 @@ +package com.getcode.model.protomapping + +import com.codeinc.gen.chat.v1.CodeChatService +import com.getcode.model.chat.Verb +import com.getcode.model.chat.Verb.Deposited +import com.getcode.model.chat.Verb.Gave +import com.getcode.model.chat.Verb.Paid +import com.getcode.model.chat.Verb.Purchased +import com.getcode.model.chat.Verb.Received +import com.getcode.model.chat.Verb.ReceivedTip +import com.getcode.model.chat.Verb.Returned +import com.getcode.model.chat.Verb.Sent +import com.getcode.model.chat.Verb.SentTip +import com.getcode.model.chat.Verb.Spent +import com.getcode.model.chat.Verb.Unknown +import com.getcode.model.chat.Verb.Withdrew + +fun Verb.Companion.invoke(proto: CodeChatService.ExchangeDataContent.Verb): Verb { + return when (proto) { + com.codeinc.gen.chat.v1.CodeChatService.ExchangeDataContent.Verb.UNKNOWN -> Unknown + com.codeinc.gen.chat.v1.CodeChatService.ExchangeDataContent.Verb.GAVE -> Gave + com.codeinc.gen.chat.v1.CodeChatService.ExchangeDataContent.Verb.RECEIVED -> Received + com.codeinc.gen.chat.v1.CodeChatService.ExchangeDataContent.Verb.WITHDREW -> Withdrew + com.codeinc.gen.chat.v1.CodeChatService.ExchangeDataContent.Verb.DEPOSITED -> Deposited + com.codeinc.gen.chat.v1.CodeChatService.ExchangeDataContent.Verb.SENT -> Sent + com.codeinc.gen.chat.v1.CodeChatService.ExchangeDataContent.Verb.RETURNED -> Returned + com.codeinc.gen.chat.v1.CodeChatService.ExchangeDataContent.Verb.SPENT -> Spent + com.codeinc.gen.chat.v1.CodeChatService.ExchangeDataContent.Verb.PAID -> Paid + com.codeinc.gen.chat.v1.CodeChatService.ExchangeDataContent.Verb.PURCHASED -> Purchased + com.codeinc.gen.chat.v1.CodeChatService.ExchangeDataContent.Verb.UNRECOGNIZED -> Unknown + com.codeinc.gen.chat.v1.CodeChatService.ExchangeDataContent.Verb.RECEIVED_TIP -> ReceivedTip + com.codeinc.gen.chat.v1.CodeChatService.ExchangeDataContent.Verb.SENT_TIP -> SentTip + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/BalanceController.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/BalanceController.kt new file mode 100644 index 000000000..1012bfe4a --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/BalanceController.kt @@ -0,0 +1,245 @@ +package com.getcode.network + +import com.codeinc.gen.user.v1.user +import com.getcode.model.Currency +import com.getcode.model.CurrencyCode +import com.getcode.model.Rate +import com.getcode.network.client.TransactionReceiver +import com.getcode.network.exchange.Exchange +import com.getcode.network.repository.AccountRepository +import com.getcode.network.repository.BalanceRepository +import com.getcode.network.repository.TransactionRepository +import com.getcode.solana.organizer.Organizer +import com.getcode.solana.organizer.Tray +import com.getcode.utils.FormatUtils +import com.getcode.utils.trace +import io.reactivex.rxjava3.core.Completable +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.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import timber.log.Timber +import xyz.flipchat.services.user.AuthState +import xyz.flipchat.services.user.UserManager +import java.util.Locale +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlin.coroutines.resume + +data class BalanceDisplay( + val marketValue: Double = 0.0, + val formattedValue: String = "", + val currency: Currency? = null, +) + +open class BalanceController @Inject constructor( + exchange: Exchange, + networkObserver: com.getcode.utils.network.NetworkConnectivityListener, + private val balanceRepository: BalanceRepository, + private val transactionRepository: TransactionRepository, + private val accountRepository: AccountRepository, + private val transactionReceiver: TransactionReceiver, + private val getCurrencyFromCode: (CurrencyCode?) -> Currency?, + private val userManager: UserManager, + val suffix: (Currency?) -> String, +) { + private val scope = CoroutineScope(Dispatchers.IO) + fun observeRawBalance(): Flow = balanceRepository.balanceFlow + + val rawBalance: Double + get() = balanceRepository.balanceFlow.value + + private val _balanceDisplay = MutableStateFlow(null) + + val formattedBalance: StateFlow + get() = _balanceDisplay + .stateIn(scope, SharingStarted.Eagerly, BalanceDisplay()) + + init { + userManager.state + .map { it.authState } + .filterIsInstance() + .flatMapLatest { networkObserver.state } + .map { it.connected } + .onEach { connected -> + if (connected) { + com.getcode.utils.network.retryable { this.getBalance() } + } + } + .flatMapLatest { + combine( + exchange.observeLocalRate() + .flowOn(Dispatchers.IO) + .onEach { + val display = _balanceDisplay.value ?: BalanceDisplay() + _balanceDisplay.value = + display.copy(currency = getCurrencyFromCode(it.currency)) + } + .onEach { exchange.fetchRatesIfNeeded() }, + balanceRepository.balanceFlow, + ) { rate, balance -> + rate to balance.coerceAtLeast(0.0) + }.map { (rate, balance) -> + refreshBalance(balance, rate) + } + }.distinctUntilChanged().onEach { (marketValue, amountText) -> + val display = _balanceDisplay.value ?: BalanceDisplay() + _balanceDisplay.value = + display.copy(marketValue = marketValue, formattedValue = amountText) + }.launchIn(scope) + } + + fun setTray(organizer: Organizer, tray: Tray) { + organizer.set(tray) + balanceRepository.setBalance(organizer.availableBalance.toKinTruncatingLong().toDouble()) + } + + fun getBalance(): Completable { + trace("fetchBalance") + val owner = userManager.keyPair + ?: return Completable.error(IllegalStateException("Missing Owner")) + + fun getTokenAccountInfos(): Completable { + return accountRepository.getTokenAccountInfos(owner) + .flatMapCompletable { infos -> + val organizer = userManager.organizer ?: return@flatMapCompletable Completable.error( + IllegalStateException("Missing Organizer") + ) + scope.launch { + organizer.setAccountInfo(infos) + userManager.set(organizer = organizer) + } + + balanceRepository.setBalance(organizer.availableBalance.toKinValueDouble()) + transactionReceiver.receiveFromIncomingCompletable(organizer) + } + .timeout(15, TimeUnit.SECONDS) + } + + return getTokenAccountInfos() + .doOnSubscribe { + Timber.i("Fetching Balance account info") + } + .onErrorResumeNext { + Timber.i("Error: ${it.javaClass.simpleName} ${it.cause}") + val organizer = userManager.organizer ?: return@onErrorResumeNext Completable.error( + IllegalStateException("Missing Organizer") + ) + + when (it) { + is AccountRepository.FetchAccountInfosException.NotFoundException -> { + transactionRepository.createAccounts( + organizer = organizer + ).ignoreElement().concatWith(getTokenAccountInfos()) + } + + else -> { + Completable.error(it) + } + } + } + } + + + suspend fun fetchBalance(): Result { + Timber.d("fetching balance") + val owner = userManager.keyPair + ?: return Result.failure(IllegalStateException("Missing Owner")) + + try { + val accountInfoResult = accountRepository.getTokenAccountInfosSuspend(owner) + accountInfoResult.exceptionOrNull()?.let { + throw it + } + + val accountInfo = accountInfoResult.getOrNull().orEmpty() + val organizer = userManager.organizer + ?: return Result.failure(IllegalStateException("Missing Organizer")) + + + organizer.setAccountInfo(accountInfo) + userManager.set(organizer = organizer) + if (organizer.isUnuseable) { + userManager.didDetectUnlockedAccount() + } + + balanceRepository.setBalance(organizer.availableBalance.toKinValueDouble()) + transactionReceiver.receiveFromIncoming(organizer) + scope.launch { + transactionRepository.swapIfNeeded(organizer) + } + + return Result.success(Unit) + } catch (ex: Exception) { + Timber.i("Error: ${ex.javaClass.simpleName} ${ex.message}") + val organizer = userManager.organizer + ?: return Result.failure(IllegalStateException("Missing Organizer")) + + return suspendCancellableCoroutine { cont -> + when (ex) { + is AccountRepository.FetchAccountInfosException.NotFoundException -> { + transactionRepository.createAccounts( + organizer = organizer + ).doOnError { cont.resume(Result.failure(it)) } + .doAfterSuccess { cont.resume(Result.success(Unit)) } + .subscribe() + } + else -> { + cont.resume(Result.failure(ex)) + } + } + } + } + } + + private fun refreshBalance(balance: Double, rate: Rate): Pair { + val preferredCurrency = getCurrencyFromCode(rate.currency) + val fiatValue = FormatUtils.getFiatValue(balance, rate.fx) + + val prefix = + formatPrefix(preferredCurrency).takeIf { it != preferredCurrency?.code }.orEmpty() + + val amountText = StringBuilder().apply { + append(prefix) + append(formatAmount(fiatValue, preferredCurrency)) + val suffix = suffix(preferredCurrency) + if (suffix.isNotEmpty()) { + append(" ") + append(suffix) + } + }.toString() + + Timber.d("formatted balance is now $prefix $amountText in ${preferredCurrency?.code}") + + return fiatValue to amountText + } + + private fun formatPrefix(selectedCurrency: Currency?): String { + if (selectedCurrency == null) return "" + return if (!isKin(selectedCurrency)) selectedCurrency.symbol else "" + } + + private fun isKin(selectedCurrency: Currency): Boolean = + selectedCurrency.code == com.getcode.model.CurrencyCode.KIN.name + + private fun formatAmount(amount: Double, currency: Currency?): String { + return if (amount % 1 == 0.0 || currency?.code == com.getcode.model.CurrencyCode.KIN.name) { + String.format(Locale.getDefault(), "%,.0f", amount) + } else { + String.format(Locale.getDefault(), "%,.2f", amount) + } + } +} diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/api/AccountApi.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/api/AccountApi.kt new file mode 100644 index 000000000..466be921c --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/api/AccountApi.kt @@ -0,0 +1,63 @@ +package com.getcode.network.api + +import com.codeinc.gen.account.v1.AccountGrpc +import com.codeinc.gen.account.v1.CodeAccountService as AccountService +import com.codeinc.gen.account.v1.CodeAccountService.LinkAdditionalAccountsRequest +import com.codeinc.gen.account.v1.CodeAccountService.LinkAdditionalAccountsResponse +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.network.repository.toSolanaAccount +import com.getcode.services.network.core.GrpcApi +import com.getcode.utils.sign +import io.grpc.ManagedChannel +import io.reactivex.rxjava3.core.Scheduler +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import xyz.flipchat.services.internal.annotations.PaymentsManagedChannel +import javax.inject.Inject + + +class AccountApi @Inject constructor( + @PaymentsManagedChannel + managedChannel: ManagedChannel, + private val scheduler: Scheduler = Schedulers.io(), +) : GrpcApi(managedChannel) { + private val api = AccountGrpc.newStub(managedChannel).withWaitForReady() + + fun isCodeAccount(owner: KeyPair): Flow { + val request = AccountService.IsCodeAccountRequest.newBuilder() + .setOwner(owner.publicKeyBytes.toSolanaAccount()) + .apply { setSignature(sign(owner)) } + .build() + + return api::isCodeAccount + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + fun getTokenAccountInfos(request: AccountService.GetTokenAccountInfosRequest): Single { + return api::getTokenAccountInfos + .callAsSingle(request) + .subscribeOn(scheduler) + } + + fun getTokenAccountInfosFlow(request: AccountService.GetTokenAccountInfosRequest): Flow { + return api::getTokenAccountInfos + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + fun linkAdditionalAccounts(owner: KeyPair, linkedAccount: KeyPair): Flow { + val request = LinkAdditionalAccountsRequest.newBuilder() + .setOwner(owner.publicKeyBytes.toSolanaAccount()) + .setSwapAuthority(linkedAccount.publicKeyBytes.toSolanaAccount()) + .let { it.addAllSignatures(listOf(it.sign(owner), it.sign(linkedAccount))) } + .build() + + return api::linkAdditionalAccounts + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/api/CurrencyApi.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/api/CurrencyApi.kt new file mode 100644 index 000000000..142b3b3dc --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/api/CurrencyApi.kt @@ -0,0 +1,23 @@ +package com.getcode.network.api + +import com.codeinc.gen.currency.v1.CurrencyGrpc +import com.codeinc.gen.currency.v1.CodeCurrencyService as CurrencyService +import com.getcode.services.network.core.GrpcApi +import io.grpc.ManagedChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import xyz.flipchat.services.internal.annotations.PaymentsManagedChannel +import javax.inject.Inject + +class CurrencyApi @Inject constructor( + @PaymentsManagedChannel + managedChannel: ManagedChannel, +) : GrpcApi(managedChannel) { + private val api = CurrencyGrpc.newStub(managedChannel).withWaitForReady() + + fun getRates(request: CurrencyService.GetAllRatesRequest = CurrencyService.GetAllRatesRequest.getDefaultInstance()): Flow = + api::getAllRates + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) +} diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/api/DeviceApi.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/api/DeviceApi.kt new file mode 100644 index 000000000..34b49bbc8 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/api/DeviceApi.kt @@ -0,0 +1,47 @@ +package com.getcode.network.api + +import com.codeinc.gen.common.v1.CodeModel as Model +import com.codeinc.gen.device.v1.DeviceGrpc +import com.codeinc.gen.device.v1.CodeDeviceService as DeviceService +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.network.repository.sign +import com.getcode.network.repository.toSolanaAccount +import com.getcode.services.network.core.GrpcApi +import io.grpc.ManagedChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import xyz.flipchat.services.internal.annotations.PaymentsManagedChannel +import javax.inject.Inject + +class DeviceApi @Inject constructor( + @PaymentsManagedChannel + managedChannel: ManagedChannel, +): GrpcApi(managedChannel) { + + private val api = DeviceGrpc.newStub(managedChannel).withWaitForReady() + + fun registerInstallation(owner: KeyPair, installationId: String) : Flow { + val request = DeviceService.RegisterLoggedInAccountsRequest.newBuilder() + .setAppInstall(Model.AppInstallId.newBuilder().setValue(installationId)) + .addOwners(owner.publicKeyBytes.toSolanaAccount()) + .apply { + addAllSignatures(listOf(sign(owner))) + } + .build() + + return api::registerLoggedInAccounts + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + fun fetchInstallationAccounts(installationId: String): Flow { + val request = DeviceService.GetLoggedInAccountsRequest.newBuilder() + .setAppInstall(Model.AppInstallId.newBuilder().setValue(installationId)) + .build() + + return api::getLoggedInAccounts + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/api/IdentityApi.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/api/IdentityApi.kt new file mode 100644 index 000000000..b6b54ba4f --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/api/IdentityApi.kt @@ -0,0 +1,55 @@ +package com.getcode.network.api + +import com.codeinc.gen.user.v1.IdentityGrpc +import com.codeinc.gen.user.v1.CodeIdentityService as IdentityService +import com.codeinc.gen.user.v1.CodeIdentityService.GetTwitterUserRequest +import com.codeinc.gen.user.v1.CodeIdentityService.LoginToThirdPartyAppRequest +import com.codeinc.gen.user.v1.CodeIdentityService.UpdatePreferencesRequest +import com.getcode.services.network.core.GrpcApi +import io.grpc.ManagedChannel +import io.reactivex.rxjava3.annotations.NonNull +import io.reactivex.rxjava3.core.Scheduler +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn +import xyz.flipchat.services.internal.annotations.PaymentsManagedChannel +import javax.inject.Inject + +class IdentityApi @Inject constructor( + @PaymentsManagedChannel + managedChannel: ManagedChannel, + private val scheduler: Scheduler = Schedulers.io(), +) : GrpcApi(managedChannel) { + private val api = IdentityGrpc.newStub(managedChannel).withWaitForReady() + + fun linkAccount(request: IdentityService.LinkAccountRequest): @NonNull Single { + return api::linkAccount + .callAsSingle(request) + .subscribeOn(scheduler) + } + + fun unlinkAccount(request: IdentityService.UnlinkAccountRequest): @NonNull Single { + return api::unlinkAccount + .callAsSingle(request) + .subscribeOn(scheduler) + } + + fun getUser(request: IdentityService.GetUserRequest): @NonNull Single { + return api::getUser + .callAsSingle(request) + .subscribeOn(scheduler) + } + + fun loginToThirdParty(request: LoginToThirdPartyAppRequest) = api::loginToThirdPartyApp + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + + fun updatePreferences(request: UpdatePreferencesRequest) = api::updatePreferences + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + + fun fetchTwitterUser(request: GetTwitterUserRequest) = api::getTwitterUser + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) +} diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/api/MessagingApi.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/api/MessagingApi.kt new file mode 100644 index 000000000..64b5c6a99 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/api/MessagingApi.kt @@ -0,0 +1,45 @@ +package com.getcode.network.api + +import com.codeinc.gen.messaging.v1.MessagingGrpc +import com.codeinc.gen.messaging.v1.CodeMessagingService.AckMessagesRequest +import com.codeinc.gen.messaging.v1.CodeMessagingService.AckMesssagesResponse +import com.codeinc.gen.messaging.v1.CodeMessagingService.OpenMessageStreamRequest +import com.codeinc.gen.messaging.v1.CodeMessagingService.OpenMessageStreamResponse +import com.codeinc.gen.messaging.v1.CodeMessagingService.PollMessagesRequest +import com.codeinc.gen.messaging.v1.CodeMessagingService.SendMessageRequest +import com.codeinc.gen.messaging.v1.CodeMessagingService.SendMessageResponse +import com.getcode.services.network.core.GrpcApi +import io.grpc.ManagedChannel +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Scheduler +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import xyz.flipchat.services.internal.annotations.PaymentsManagedChannel +import javax.inject.Inject + +class MessagingApi @Inject constructor( + @PaymentsManagedChannel + managedChannel: ManagedChannel, + private val scheduler: Scheduler = Schedulers.io() +) : GrpcApi(managedChannel) { + private val api = MessagingGrpc.newStub(managedChannel).withWaitForReady() + + fun openMessageStream(request: OpenMessageStreamRequest): Flowable = + api::openMessageStream + .callAsCancellableFlowable(request) + .subscribeOn(scheduler) + + fun ackMessages(request: AckMessagesRequest): Single = + api::ackMessages + .callAsSingle(request) + .subscribeOn(scheduler) + + fun sendMessage(request: SendMessageRequest): Single = + api::sendMessage + .callAsSingle(request) + .subscribeOn(scheduler) + + fun pollMessages(request: PollMessagesRequest) = api::pollMessages + .callAsSingle(request) + .subscribeOn(scheduler) +} diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/api/TransactionApiV2.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/api/TransactionApiV2.kt new file mode 100644 index 000000000..b7b6b8c1f --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/api/TransactionApiV2.kt @@ -0,0 +1,75 @@ +package com.getcode.network.api + +import com.codeinc.gen.transaction.v2.CodeTransactionService +import com.codeinc.gen.transaction.v2.TransactionGrpc +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import com.getcode.services.network.core.GrpcApi +import io.grpc.ManagedChannel +import io.grpc.stub.StreamObserver +import io.reactivex.rxjava3.core.Scheduler +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import xyz.flipchat.services.internal.annotations.PaymentsManagedChannel +import javax.inject.Inject + +class TransactionApiV2 @Inject constructor( + @PaymentsManagedChannel + managedChannel: ManagedChannel, + private val scheduler: Scheduler = Schedulers.io(), +) : GrpcApi(managedChannel) { + private val api = TransactionGrpc.newStub(managedChannel).withWaitForReady() + + fun submitIntent(request: StreamObserver): StreamObserver { + return api.submitIntent(request) + } + + fun airdrop(request: TransactionService.AirdropRequest): Single { + return api::airdrop + .callAsSingle(request) + .subscribeOn(scheduler) + } + + fun getPrivacyUpgradeStatus(request: TransactionService.GetPrivacyUpgradeStatusRequest): Single { + return api::getPrivacyUpgradeStatus + .callAsSingle(request) + .subscribeOn(scheduler) + } + + fun getLimits(request: TransactionService.GetLimitsRequest): Single { + return api::getLimits + .callAsSingle(request) + .subscribeOn(scheduler) + } + + fun getIntentMetadata(request: TransactionService.GetIntentMetadataRequest): Single { + return api::getIntentMetadata + .callAsSingle(request) + .subscribeOn(scheduler) + } + + fun canWithdrawToAccount(request: TransactionService.CanWithdrawToAccountRequest): Single { + return api::canWithdrawToAccount + .callAsSingle(request) + .subscribeOn(scheduler) + } + + fun getPrioritizedIntentsForPrivacyUpgrade(request: TransactionService.GetPrioritizedIntentsForPrivacyUpgradeRequest): Single { + return api::getPrioritizedIntentsForPrivacyUpgrade + .callAsSingle(request) + .subscribeOn(scheduler) + } + + fun swap(observer: StreamObserver): StreamObserver { + return api.swap(observer) + } + + fun declareFiatPurchase(request: TransactionService.DeclareFiatOnrampPurchaseAttemptRequest): Flow { + return api::declareFiatOnrampPurchaseAttempt + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + +} diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/client/Client.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/client/Client.kt new file mode 100644 index 000000000..282c248d0 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/client/Client.kt @@ -0,0 +1,96 @@ +package com.getcode.network.client + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.OnLifecycleEvent +import com.codeinc.gen.user.v1.user +import com.getcode.network.BalanceController +import com.getcode.network.exchange.Exchange +import com.getcode.network.repository.AccountRepository +import com.getcode.network.repository.MessagingRepository +import com.getcode.network.repository.TransactionRepository +import com.getcode.services.analytics.AnalyticsService +import com.getcode.utils.ErrorUtils +import com.getcode.utils.network.NetworkConnectivityListener +import com.getcode.vendor.Base58 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import timber.log.Timber +import xyz.flipchat.services.user.AuthState +import xyz.flipchat.services.user.UserManager +import java.util.Timer +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.concurrent.fixedRateTimer +import kotlin.time.Duration.Companion.seconds + +internal const val TAG = "Client" + +@Singleton +class Client @Inject constructor( + internal val userManager: UserManager, + internal val transactionRepository: TransactionRepository, + internal val messagingRepository: MessagingRepository, + internal val balanceController: BalanceController, + internal val accountRepository: AccountRepository, + internal val exchange: Exchange, + internal val transactionReceiver: TransactionReceiver, + internal val networkObserver: NetworkConnectivityListener, +) { + private val scope = CoroutineScope(Dispatchers.IO) + + private var pollTimer: Timer? = null + private var lastPoll: Long = 0L + + private fun startPollTimerWhenAuthenticated() { + Timber.tag(TAG).i("Creating poll timer") + scope.launch { + while(userManager.authState !is AuthState.LoggedIn) { + delay(1.seconds) + if (userManager.authState is AuthState.LoggedIn) { + startPollTimer() + break + } + } + } + } + + private fun startPollTimer() { + pollTimer?.cancel() + pollTimer = fixedRateTimer("pollTimer", false, 0, 1000 * 10) { + scope.launch { + val time = System.currentTimeMillis() + val isPastThrottle = time - lastPoll > 1000 * 30 || lastPoll == 0L + + if (userManager.authState is AuthState.LoggedIn && isPastThrottle) { + Timber.tag(TAG).i("Timer Polling") + poll() + lastPoll = time + } + } + } + } + + private suspend fun poll() { + if (networkObserver.isConnected) { + try { + balanceController.getBalance() + exchange.fetchRatesIfNeeded() + } catch (e: Exception) { + ErrorUtils.handleError(e) + } + fetchLimits().andThen(fetchPrivacyUpgrades()).blockingSubscribe() + } + } + + fun startTimer() { + startPollTimerWhenAuthenticated() + } + + fun stopTimer() { + Timber.tag(TAG).i("Cancelling Poller") + pollTimer?.cancel() + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/client/Client_Transaction.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/client/Client_Transaction.kt new file mode 100644 index 000000000..f644c1a84 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/client/Client_Transaction.kt @@ -0,0 +1,593 @@ +package com.getcode.network.client + +import android.annotation.SuppressLint +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.model.AccountInfo +import com.getcode.model.Domain +import com.getcode.model.Fee +import com.getcode.model.ID +import com.getcode.model.IntentMetadata +import com.getcode.model.Kin +import com.getcode.model.KinAmount +import com.getcode.model.Limits +import com.getcode.model.Rate +import com.getcode.model.generate +import com.getcode.model.intents.IntentDeposit +import com.getcode.model.intents.IntentEstablishRelationship +import com.getcode.model.intents.IntentPrivateTransfer +import com.getcode.model.intents.IntentPublicTransfer +import com.getcode.model.intents.IntentRemoteSend +import com.getcode.model.intents.SwapIntent +import com.getcode.network.repository.TransactionRepository +import com.getcode.network.repository.WithdrawException +import com.getcode.network.repository.initiateSwap +import com.getcode.services.model.ExtendedMetadata +import com.getcode.services.utils.flowInterval +import com.getcode.services.utils.mapResult +import com.getcode.services.utils.toKotlinResult +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.organizer.GiftCardAccount +import com.getcode.solana.organizer.Organizer +import com.getcode.solana.organizer.Relationship +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.takeWhile +import timber.log.Timber +import java.util.Calendar +import java.util.GregorianCalendar +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.min + + +fun Client.transfer( + amount: KinAmount, + fee: Kin, + additionalFees: List, + organizer: Organizer, + rendezvousKey: PublicKey, + destination: PublicKey, + isWithdrawal: Boolean, + metadata: ExtendedMetadata? = null, +): Completable { + return transferWithResultSingle( + amount, + fee, + additionalFees, + organizer, + rendezvousKey, + destination, + isWithdrawal + ).flatMapCompletable { + if (it.isSuccess) { + Timber.d("transfer successful") + Completable.complete() + } else { + Completable.error(it.exceptionOrNull() ?: Throwable("Failed to complete transfer")) + } + } +} + +suspend fun Client.publicPayment( + amount: KinAmount, + organizer: Organizer, + destination: PublicKey, + extendedMetadata: ExtendedMetadata? = null, +): Result = getTransferPreflightAction(amount.kin).toKotlinResult() + .mapResult { transactionRepository.publicPayment(amount, organizer, destination, extendedMetadata) } + .map { it.id.bytes } + +fun Client.transferWithResultSingle( + amount: KinAmount, + fee: Kin, + additionalFees: List, + organizer: Organizer, + rendezvousKey: PublicKey, + destination: PublicKey, + isWithdrawal: Boolean, + metadata: ExtendedMetadata? = null, +): Single> { + return getTransferPreflightAction(amount.kin) + .andThen(Single.defer { + transactionRepository.transfer( + amount, + fee, + additionalFees, + organizer, + rendezvousKey, + destination, + isWithdrawal, + metadata + ) + }) + .map { + if (it is IntentPrivateTransfer) { + balanceController.setTray(organizer, it.resultTray) + } + it + }.map { Result.success(it.id.bytes) } + .onErrorReturn { Result.failure(it) } +} + +fun Client.transferWithResult( + amount: KinAmount, + fee: Kin, + additionalFees: List, + organizer: Organizer, + rendezvousKey: PublicKey, + destination: PublicKey, + isWithdrawal: Boolean, + metadata: ExtendedMetadata? = null, +): Result { + return transferWithResultSingle( + amount = amount, + fee = fee, + additionalFees = additionalFees, + organizer = organizer, + rendezvousKey = rendezvousKey, + destination = destination, + isWithdrawal = isWithdrawal, + metadata = metadata, + ).blockingGet() +} + + +fun Client.sendRemotely( + amount: KinAmount, + rendezvousKey: PublicKey, + giftCard: GiftCardAccount +): Completable { + return Completable.defer { + val organizer = userManager.organizer ?: return@defer Completable.complete() + val truncatedAmount = amount.truncating() + getTransferPreflightAction(truncatedAmount.kin) + .andThen( + sendRemotely( + amount = truncatedAmount, + organizer = organizer, + rendezvousKey = rendezvousKey, + giftCard = giftCard + ) + ) + } +} + +fun Client.receiveRemote(giftCard: GiftCardAccount): Single { + // Before we can receive from the gift card account + // we have to determine the balance of the account + return accountRepository.getTokenAccountInfos(giftCard.cluster.authority.keyPair) + .flatMap { infos -> + val info: AccountInfo = infos.values.firstOrNull() + ?: return@flatMap Single.error(RemoteSendException.FailedToFetchGiftCardInfoException()) + val kinAmount = info.originalKinAmount + ?: return@flatMap Single.error(RemoteSendException.GiftCardBalanceNotFoundException()) + + if (info.claimState == AccountInfo.ClaimState.Claimed) { + return@flatMap Single.error(RemoteSendException.GiftCardClaimedException()) + } + + if (info.claimState == AccountInfo.ClaimState.Claimed || info.claimState == AccountInfo.ClaimState.Unknown) { + return@flatMap Single.error(RemoteSendException.GiftCardExpiredException()) + } + + val organizer = userManager.organizer ?: return@flatMap Single.error(Throwable("No organizer")) + + transactionReceiver.receiveRemotely( + giftCard = giftCard, + amount = info.balance, + organizer = organizer, + isVoiding = false + ) + .toSingleDefault(kinAmount) + } +} + +@SuppressLint("CheckResult") +suspend fun Client.cancelRemoteSend( + giftCard: GiftCardAccount, + amount: Kin, + organizer: Organizer +): Result = runCatching { + transactionReceiver.receiveRemotely( + amount = amount, + organizer = organizer, + giftCard = giftCard, + isVoiding = true + ).blockingAwait() + + balanceController.getBalance() + + balanceController.rawBalance +} + + +sealed class RemoteSendException : Exception() { + class FailedToFetchGiftCardInfoException : RemoteSendException() + class GiftCardBalanceNotFoundException : RemoteSendException() + class GiftCardClaimedException : RemoteSendException() + class GiftCardExpiredException : RemoteSendException() +} + +fun Client.withdrawExternally( + amount: KinAmount, + organizer: Organizer, + destination: PublicKey, +): Completable { + if (amount.kin.fractionalQuarks().quarks != 0L) { + throw WithdrawException.InvalidFractionalKinAmountException() + } + + if (amount.kin > organizer.availableBalance) { + throw WithdrawException.InsufficientFundsException() + } + + val intent = PublicKey.generate() + + val steps = mutableListOf() + steps.add("Attempting withdrawal...") + val primaryBalance = organizer.availableDepositBalance.toKinTruncating() + + // If the primary account has less Kin than the amount + // requested for withdrawal, we'll need to execute a + // private transfer to the primary account before we + // can make a public transfer to destination + return if (primaryBalance < amount.kin) { + var missingBalance = amount.kin - primaryBalance + steps.add("Amount exceeds primary balance.") + steps.add("Missing balance: $missingBalance") + + // 1. If we're missing funds, we'll pull funds + // from relationship accounts first. + if (missingBalance > 0) { + val receivedFromRelationships = + transactionReceiver.receiveFromRelationship(organizer, limit = missingBalance) + missingBalance -= receivedFromRelationships + + steps.add("Pulled from relationships: $receivedFromRelationships") + steps.add("Missing balance: $missingBalance") + } + + // 2. If we still need funds to fulfill the withdrawal + // it's likely that they are stuck in incoming and bucket + // accounts. We'll need to pull those out into primary. + if (missingBalance > 0) { + + // 3. It's possible that there's funds still left in + // an incoming account. If we're still missing funds + // for withdrawal, we'll pull from incoming. + if (transactionReceiver.availableIncomingAmount(organizer) > 0) { + val receivedFromIncoming = transactionReceiver.receiveFromIncoming( + organizer = organizer + ) + missingBalance -= receivedFromIncoming + + steps.add("Pulled from incoming: $receivedFromIncoming") + steps.add("Missing balance: $missingBalance") + } + } + + + // 4. In the event that it's a full withdrawal or if + // more funds are required, we'll need to do a private + // transfer from bucket accounts. + if (missingBalance > 0) { + // Move funds into primary from buckets + transfer( + amount = KinAmount.newInstance(kin = missingBalance, rate = Rate.oneToOne), + fee = Kin.fromKin(0), + additionalFees = emptyList(), + organizer = organizer, + rendezvousKey = intent, + destination = organizer.primaryVault, + isWithdrawal = true + ).doOnComplete { + steps.add("Pulled from buckets: $missingBalance") + }.concatWith(fetchLimits()).concatWith(balanceController.getBalance()) + } else { + // 5. Update balances and limits after the withdrawal since + // it's likely that this withdrawal affected both but at the + // very least, we need updated balances for all accounts. + balanceController.getBalance() + } + } else { + Completable.complete() + }.doOnComplete { + Timber.d(steps.joinToString("\n")) + }.concatWith( + // 6. Execute withdrawal + withdraw( + amount = amount, + organizer = organizer, + destination = destination, + ) + ).doOnComplete { + trace( + tag = "Trx", + message = "Withdraw completed", + type = TraceType.Process + ) + } +} + +private fun Client.withdraw( + amount: KinAmount, + organizer: Organizer, + destination: PublicKey +): Completable { + return Completable.defer { + transactionRepository.withdraw( + amount, organizer, destination + ) + .map { + if (it is IntentPublicTransfer) { + balanceController.setTray(organizer, it.resultTray) + } + } + .ignoreElement() + } +} + +fun Client.sendRemotely( + amount: KinAmount, + organizer: Organizer, + rendezvousKey: PublicKey, + giftCard: GiftCardAccount +): Completable { + return Completable.defer { + transactionRepository.sendRemotely( + amount, organizer, rendezvousKey, giftCard + ) + .map { + if (it is IntentRemoteSend) { + balanceController.setTray(organizer, it.resultTray) + } + } + .ignoreElement() + } +} + + +suspend fun Client.requestFirstKinAirdrop( + owner: KeyPair, +): Result { + Timber.d("requesting airdrop") + return transactionRepository.requestFirstKinAirdrop(owner) +} + +fun Client.pollIntentMetadata( + owner: KeyPair, + intentId: PublicKey, + maxAttempts: Int = 50, + debugLogs: Boolean = false, +): Flow { + val stopped = AtomicBoolean() + val attemptCount = AtomicInteger() + + if (debugLogs) { + Timber.tag("codescan").i("pollIntentMetadata: start polling") + } + + return flowInterval({ 50L * (attemptCount.get() / 10) }) + .takeWhile { !stopped.get() && attemptCount.get() < maxAttempts } + .map { attemptCount.incrementAndGet() } + .onEach { + if (debugLogs) { + Timber.tag("codescan").i("pollIntentMetadata: [${it}] fetch data") + } + } + .map { transactionRepository.fetchIntentMetadata(owner, intentId) } + .filter { !stopped.get() } + .mapNotNull { it.getOrNull() } + .map { + if (debugLogs) { + Timber.tag("codescan") + .i("pollMatchingRendezvous: stop polling :: took ${attemptCount.get()} attempts") + } + stopped.set(true) + it + } +} + +fun Client.fetchTransactionLimits( + owner: KeyPair, + isForce: Boolean = false +): Limits? { + val time = System.currentTimeMillis() + + val isStale = transactionRepository.areLimitsState + + if (!isStale && !isForce) { + return transactionRepository.limits + } + + Timber.i("fetchTransactionLimits") + lastLimitsFetch = time + + val date: Calendar = GregorianCalendar() + date.set(Calendar.HOUR_OF_DAY, 0) + date.set(Calendar.MINUTE, 0) + date.set(Calendar.SECOND, 0) + date.set(Calendar.MILLISECOND, 0) + + val seconds = date.timeInMillis / 1000 + return transactionRepository.fetchLimits(owner, seconds) + .subscribeOn(Schedulers.io()) + .blockingFirst() +} + +fun Client.fetchDestinationMetadata(destination: PublicKey): Single { + return transactionRepository.fetchDestinationMetadata(destination) +} + +// ----- +private var lastLimitsFetch: Long = 0L + +fun Client.fetchLimits(isForce: Boolean = false): Completable { + val owner = userManager.keyPair ?: return Completable.complete() + fetchTransactionLimits(owner, isForce) + return Completable.complete() +} + +fun Client.receiveIfNeeded(): Completable { + val organizer = userManager.organizer ?: return Completable.complete() + + if (organizer.slotsBalance < transactionRepository.maxDeposit) { + receiveFromRelationships(organizer, upTo = transactionRepository.maxDeposit) + } + + return Completable.concatArray( + receiveFromPrimaryIfWithinLimits(organizer), + transactionReceiver.receiveFromIncomingCompletable(organizer) + ) +} + +fun Client.receiveFromPrimaryIfWithinLimits(organizer: Organizer): Completable { + Timber.d("receive within limits") + val depositBalance = organizer.availableDepositBalance.toKinTruncating() + + // Nothing to deposit + if (!depositBalance.hasWholeKin()) { + Timber.d("nothing to deposit ($depositBalance)") + return Completable.complete() + } + + // We want to deposit the smaller of the two: balance in the + // primary account or the max allowed amount provided by server + return Single.just(transactionRepository.maxDeposit.toKinTruncatingLong()) + .map { maxDeposit -> + Pair( + Kin.fromKin(min(depositBalance.toKinValueDouble(), maxDeposit.toDouble())), + Kin.fromKin(maxDeposit) + ) + } + .filter { pair -> + val (depositAmount, _) = pair + depositAmount.hasWholeKin().also { Timber.d("hasWholeKin=$it") } + } + .flatMapSingle { pair -> + val (depositAmount, maxDeposit) = pair + Timber.i( + "Receiving from primary: ${depositAmount.toKin()}, Max allowed deposit: ${maxDeposit.toKin()}" + ) + transactionRepository.receiveFromPrimary(depositAmount, organizer) + } + .map { intent -> + if (intent is IntentDeposit) { + balanceController.setTray(organizer, intent.resultTray) + } + } + .doOnSuccess { + trace( + tag = "Trx", + message = "Received from primary", + type = TraceType.Process + ) + fetchLimits(isForce = true) + }.ignoreElement() +} + +fun Client.fetchPrivacyUpgrades(): Completable { + val owner = userManager.keyPair ?: return Completable.complete() + val organizer = userManager.organizer ?: return Completable.complete() + + return transactionRepository.fetchUpgradeableIntents(owner) + .flatMapCompletable { intents -> + Timber.w("Fetch Privacy size: ${intents.size}") + val completableList = mutableListOf() + + intents.forEachIndexed { index, intent -> + val completable = + transactionRepository.upgradePrivacy( + organizer.mnemonic, + intent + ).ignoreElement() + + completableList.add(completable) + } + + Completable.mergeArray(*completableList.toTypedArray()) + } +} + +fun Client.getTransferPreflightAction(amount: Kin): Completable { + val organizer = userManager.organizer ?: return Completable.complete() + val neededKin = + if (amount > organizer.slotsBalance) amount - organizer.slotsBalance else Kin.fromKin(0) + + // If the there's insufficient funds in the slots + // we'll need to top them up from incoming, relationship + // and primary accounts, in that order. + return if (neededKin > 0) { + // 1. Receive funds from incoming accounts as those + // will rotate more frequently than other types of accounts + val receivedKin = transactionReceiver.receiveFromIncoming(organizer) + Timber.d("received ${receivedKin.quarks} from incoming") + // 2. Pull funds from relationships if there's still funds + // missing in buckets after the receiving from primary + if (receivedKin < neededKin) { + Timber.d("attempt to pull funds from relationship to get to ${neededKin.quarks}") + val result = transactionReceiver.receiveFromRelationship( + organizer, + limit = neededKin - receivedKin + ) + Timber.d("received ${result.quarks} from relationships") + } + + // 3. If the amount is still larger than what's available + // in the slots, we'll need to move funds from primary + // deposits into slots after receiving + if (amount > organizer.slotsBalance) { + Timber.d("receive from primary") + receiveFromPrimaryIfWithinLimits(organizer) + } else { + Completable.complete() + } + } else { + Completable.complete() + } +} + +fun Client.receiveFromRelationships(organizer: Organizer, upTo: Kin? = null): Kin { + return transactionReceiver.receiveFromRelationship(organizer, upTo) +} + +@SuppressLint("CheckResult") +@Throws +fun Client.establishRelationshipSingle( + organizer: Organizer, + domain: Domain +): Single { + return transactionRepository.establishRelationshipSingle(organizer, domain) +} + +@Suppress("RedundantSuspendModifier") +@SuppressLint("CheckResult") +@Throws +suspend fun Client.awaitEstablishRelationship( + organizer: Organizer, + domain: Domain +): Result { + return transactionRepository.establishRelationship(organizer, domain) + .map { it.relationship } +} + +suspend fun Client.initiateSwap(organizer: Organizer): Result { + return transactionRepository.initiateSwap(organizer) +} + +suspend fun Client.declareFiatPurchase( + owner: KeyPair, + amount: KinAmount, + nonce: UUID +): Result { + return transactionRepository.declareFiatPurchase(owner, amount, nonce) +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/client/TransactionReceiver.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/client/TransactionReceiver.kt new file mode 100644 index 000000000..85274be36 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/client/TransactionReceiver.kt @@ -0,0 +1,159 @@ +package com.getcode.network.client + +import com.getcode.model.Kin +import com.getcode.model.intents.IntentPublicTransfer +import com.getcode.model.intents.IntentReceive +import com.getcode.model.intents.IntentRemoteReceive +import com.getcode.network.repository.BalanceRepository +import com.getcode.network.repository.TransactionRepository +import com.getcode.solana.organizer.GiftCardAccount +import com.getcode.solana.organizer.Organizer +import com.getcode.solana.organizer.Tray +import com.getcode.utils.ErrorUtils +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import io.reactivex.rxjava3.core.Completable +import timber.log.Timber +import javax.inject.Inject + +class TransactionReceiver @Inject constructor( + private val balanceRepository: BalanceRepository, + private val transactionRepository: TransactionRepository +) { + fun receiveRemotely( + amount: Kin, + organizer: Organizer, + giftCard: GiftCardAccount, + isVoiding: Boolean + ): Completable { + return Completable.defer { + transactionRepository.receiveRemotely( + amount, organizer, giftCard, isVoiding + ) + .map { + if (it is IntentRemoteReceive) { + setTray(organizer, it.resultTray) + } + } + .ignoreElement() + } + } + + suspend fun receiveRemotelySuspend( + giftCard: GiftCardAccount, + amount: Kin, + organizer: Organizer, + isVoiding: Boolean + ) { + val intent = transactionRepository.receiveRemotely( + amount = amount, + organizer = organizer, + giftCard = giftCard, + isVoiding = isVoiding + ).blockingGet() + + if (intent is IntentRemoteReceive) { + setTray(organizer, intent.resultTray) + } + } + + fun receiveFromRelationship(organizer: Organizer, limit: Kin? = null): Kin { + var receivedTotal = Kin.fromKin(0) + + runCatching loop@{ + organizer.relationshipsLargestFirst().onEach { relationship -> + Timber.d("Receiving from relationships: domain ${relationship.domain.urlString} balance ${relationship.partialBalance}") + + // Ignore empty relationship accounts + if (relationship.partialBalance > 0) { + val intent = transactionRepository.receiveFromRelationship( + relationship = relationship, + organizer = organizer + ).blockingGet() + + trace( + tag = "Trx", + message = "Received from relationship", + type = TraceType.Process, + metadata = { + "domain" to relationship.domain.relationshipHost + "kin" to relationship.partialBalance + } + ) + + receivedTotal += relationship.partialBalance + + trace( + tag = "Trx", + message = "Received from incoming", + type = TraceType.Process + ) + + if (intent is IntentPublicTransfer) { + setTray(organizer, intent.resultTray) + } + + // Bail early if a limit is set + if (limit != null && receivedTotal >= limit) { + return@loop // break loop + } + } + } + }.onFailure { + ErrorUtils.handleError(it) + it.printStackTrace() + } + + return receivedTotal + } + + fun receiveFromIncoming(organizer: Organizer): Kin { + val incomingBalance = availableIncomingAmount(organizer) + return if (incomingBalance <= 0) { + Kin.fromKin(0) + } else { + receiveFromIncoming( + amount = incomingBalance, + organizer = organizer + ).blockingAwait() + incomingBalance + } + } + + fun receiveFromIncomingCompletable(organizer: Organizer): Completable { + val incomingBalance = availableIncomingAmount(organizer) + return if (incomingBalance <= 0) { + Completable.complete() + } else { + receiveFromIncoming( + amount = incomingBalance, + organizer = organizer + ) + } + } + + fun receiveFromIncoming(amount: Kin, organizer: Organizer): Completable { + trace( + "receiveFromIncoming $amount", + type = TraceType.Silent + ) + return transactionRepository.receiveFromIncoming(amount, organizer).map { + if (it is IntentReceive) { + setTray(organizer, it.resultTray) + } + }.ignoreElement() + } + + suspend fun swapIfNeeded(organizer: Organizer) { + transactionRepository.swapIfNeeded(organizer) + } + + private fun setTray(organizer: Organizer, tray: Tray) { + organizer.set(tray) + balanceRepository.setBalance(organizer.availableBalance.toKinTruncatingLong().toDouble()) + } + + fun availableIncomingAmount(organizer: Organizer): Kin { + return organizer.availableIncomingBalance.toKinTruncating() + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/exchange/Exchange.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/exchange/Exchange.kt new file mode 100644 index 000000000..664c8a6c0 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/exchange/Exchange.kt @@ -0,0 +1,226 @@ +package com.getcode.network.exchange + +import com.getcode.model.Rate +import com.getcode.network.service.CurrencyService +import com.getcode.util.format +import com.getcode.utils.TraceType +import com.getcode.utils.network.retryable +import com.getcode.utils.trace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.datetime.Instant +import java.util.Date +import javax.inject.Inject +import kotlin.time.Duration.Companion.minutes + + +class CodeExchange @Inject constructor( + private val currencyService: CurrencyService, + private val preferredCurrency: suspend () -> com.getcode.model.Currency?, + private val defaultCurrency: suspend () -> com.getcode.model.Currency?, +) : Exchange, CoroutineScope by CoroutineScope(Dispatchers.IO) { + + private var _entryRate = MutableStateFlow(Rate.oneToOne) + override val entryRate: Rate + get() = _entryRate.value + + override fun observeEntryRate(): Flow = _entryRate + + private val _localRate = MutableStateFlow(Rate.oneToOne) + override val localRate + get() = _localRate.value + + override fun observeLocalRate(): Flow = _localRate + + private var rateDate: Long = System.currentTimeMillis() + + private var localCurrency: com.getcode.model.CurrencyCode? = null + private var entryCurrency: com.getcode.model.CurrencyCode? = null + + private val _rates = MutableStateFlow(emptyMap()) + private var rates = RatesBox(0, emptyMap()) + set(value) { + field = value + _rates.value = value.rates + } + + override fun rates() = rates.rates + override fun observeRates(): Flow> = _rates + + private val isStale: Boolean + get() { + if (rates.rates.isEmpty()) return true + // Remember, the exchange rates date is the server-provided + // date-of-rate and not the time the rate was fetched. It + // might be reasonable for the server to return a date that + // is dated 11 minutes or older. + val threshold = 20.minutes.inWholeMilliseconds + return System.currentTimeMillis() - rates.dateMillis > threshold + } + + init { + launch { + localCurrency = com.getcode.model.CurrencyCode.tryValueOf(preferredCurrency()?.code.orEmpty()) + entryCurrency = com.getcode.model.CurrencyCode.tryValueOf(defaultCurrency()?.code.orEmpty()) + +// prefs.observeOrDefault(PrefsString.KEY_ENTRY_CURRENCY, "") +// .map { it.takeIf { it.isNotEmpty() } } +// .map { com.getcode.model.CurrencyCode.tryValueOf(it.orEmpty()) } +// .mapNotNull { preferred -> +// preferred ?: com.getcode.model.CurrencyCode.tryValueOf(defaultCurrency()?.code.orEmpty()) +// }.onEach { setEntryCurrency(it) } +// .launchIn(this@CodeExchange) + } + + launch { +// db?.exchangeDao()?.query()?.let { exchangeData -> +// val rates = exchangeData.map { Rate(it.fx, it.currency) } +// val dateMillis = exchangeData.minOf { it.synced } +// set(RatesBox(dateMillis = dateMillis, rates = rates)) +// } + + fetchRatesIfNeeded() + } + +// prefs.observeOrDefault(PrefsString.KEY_LOCAL_CURRENCY, "") +// .map { it.takeIf { it.isNotEmpty() } } +// .map { com.getcode.model.CurrencyCode.tryValueOf(it.orEmpty()) } +// .mapNotNull { preferred -> +// preferred ?: com.getcode.model.CurrencyCode.tryValueOf(defaultCurrency()?.code.orEmpty()) +// }.onEach { setLocalCurrency(it) } +// .launchIn(this) + } + + override suspend fun fetchRatesIfNeeded() { + if (isStale) { + retryable( + call = { + currencyService.getRates() + .onSuccess { (updatedRates, date) -> +// db?.exchangeDao()?.insert(rates = updatedRates, syncedAt = date) + set(RatesBox(date, updatedRates)) + } + } + ) + } + + updateRates() + } + + private fun setEntryCurrency(currency: com.getcode.model.CurrencyCode) { + entryCurrency = currency + updateRates() + } + + private fun setLocalCurrency(currency: com.getcode.model.CurrencyCode) { + localCurrency = currency + updateRates() + } + + private suspend fun set(ratesBox: RatesBox) { + rates = ratesBox + rateDate = ratesBox.dateMillis + + setLocalEntryCurrencyIfNeeded() + updateRates() + } + + private suspend fun setLocalEntryCurrencyIfNeeded() { + if (entryCurrency != null) { + return + } + + val localRegionCurrency = defaultCurrency() ?: return + val currency = com.getcode.model.CurrencyCode.tryValueOf(localRegionCurrency.code) + entryCurrency = currency + } + + override fun rateFor(currencyCode: com.getcode.model.CurrencyCode): Rate? = rates.rateFor(currencyCode) + + override fun rateForUsd(): Rate? = rates.rateForUsd() + + private fun updateRates() { + if (rates.isEmpty) { + return + } + + val localRate = localCurrency?.let { rates.rateFor(it) } + val localChanged = _localRate.value != localRate + if (localChanged) { + _localRate.value = if (localRate != null) { + trace( + tag = "Background", + message = "Updated the local currency: $localCurrency, " + + "Staleness ${System.currentTimeMillis() - rates.dateMillis} ms, " + + "Date: ${Date(rates.dateMillis)}", + type = TraceType.Process + ) + localRate + } else { + trace( + tag = "Background", + message = "local:: Rate for $localCurrency not found. Defaulting to USD.", + type = TraceType.Process + ) + rates.rateForUsd()!! + } + } + + + val entryRate = entryCurrency?.let { rates.rateFor(it) } + val entryChanged = _entryRate.value != entryRate + if (entryChanged) { + _entryRate.value = if (entryRate != null) { + trace( + tag = "Background", + message = "Updated the entry currency: $entryCurrency, " + + "Staleness ${System.currentTimeMillis() - rates.dateMillis} ms, " + + "Date: ${Date(rates.dateMillis)}", + type = TraceType.Process + ) + entryRate + } else { + trace( + tag = "Background", + message = "entry:: Rate for $entryCurrency not found. Defaulting to USD.", + type = TraceType.Process + ) + rates.rateForUsd()!! + } + } + + if (localChanged || entryChanged) { + trace(tag = "Background", + message = "Updated rates", + type = TraceType.Process, + metadata = { + "date" to Instant.fromEpochMilliseconds(rates.dateMillis) + .format("yyyy-MM-dd HH:mm:ss") + } + ) + } + } +} + +private data class RatesBox(val dateMillis: Long, val rates: Map) { + constructor(dateMillis: Long, rates: List) : this( + dateMillis, + rates.associateBy { it.currency }) + + val isEmpty: Boolean + get() = rates.isEmpty() + + fun rateFor(currencyCode: com.getcode.model.CurrencyCode): Rate? = rates[currencyCode] + + fun rateFor(currency: com.getcode.model.Currency): Rate? { + val currencyCode = com.getcode.model.CurrencyCode.tryValueOf(currency.code) + return currencyCode?.let { rates[it] } + } + + fun rateForUsd(): Rate? { + return rates[com.getcode.model.CurrencyCode.USD] + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/AccountRepository.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/AccountRepository.kt new file mode 100644 index 000000000..dc85a9bd7 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/AccountRepository.kt @@ -0,0 +1,116 @@ +package com.getcode.network.repository + +import com.codeinc.gen.account.v1.CodeAccountService as AccountService +import com.getcode.ed25519.Ed25519 +import com.getcode.model.* +import com.getcode.network.api.AccountApi +import com.getcode.services.network.core.NetworkOracle +import com.getcode.solana.keys.PublicKey +import com.getcode.utils.getPublicKeyBase58 +import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import timber.log.Timber +import javax.inject.Inject + +private const val TAG = "AccountRepository" + +@Deprecated("Replaced with Account Service") +class AccountRepository @Inject constructor( + private val accountApi: AccountApi, + private val networkOracle: NetworkOracle, +) { + suspend fun getTokenAccountInfosSuspend( + owner: Ed25519.KeyPair, + ): Result> { + val request = AccountService.GetTokenAccountInfosRequest.newBuilder() + .setOwner(owner.publicKeyBytes.toSolanaAccount()) + .apply { setSignature(sign(owner)) } + .build() + + Timber.d("token info fetch") + return try { + networkOracle.managedRequest(accountApi.getTokenAccountInfosFlow(request)) + .map { response -> + when (response.result) { + AccountService.GetTokenAccountInfosResponse.Result.OK -> { + Timber.d("token account infos fetched") + val container = mutableMapOf() + + for ((base58, info) in response.tokenAccountInfosMap) { + val account = PublicKey.fromBase58(base58) + val accountInfo = AccountInfo.newInstance(info) + if (accountInfo == null) { + Timber.i("Failed to parse account info: $info") + continue + } + + container[account] = accountInfo + } + Timber.d("token account infos handled") + Result.success(container) + } + + AccountService.GetTokenAccountInfosResponse.Result.NOT_FOUND -> { + Timber.i("Account not found for owner: ${owner.getPublicKeyBase58()}") + Result.failure(FetchAccountInfosException.NotFoundException()) + } + + else -> { + Timber.i("Unknown exception") + Result.failure(FetchAccountInfosException.UnknownException()) + } + } + } + .first() + } catch (e: Exception) { + Result.failure(e) + } + } + + fun getTokenAccountInfos( + owner: Ed25519.KeyPair, + ): Single> { + val request = AccountService.GetTokenAccountInfosRequest.newBuilder() + .setOwner(owner.publicKeyBytes.toSolanaAccount()) + .apply { setSignature(sign(owner)) } + .build() + + Timber.d("token info fetch") + return accountApi.getTokenAccountInfos(request) + .flatMap { response -> + when (response.result) { + AccountService.GetTokenAccountInfosResponse.Result.OK -> { + Timber.d("token account infos fetched") + val container = mutableMapOf() + + for ((base58, info) in response.tokenAccountInfosMap) { + val account = PublicKey.fromBase58(base58) + val accountInfo = AccountInfo.newInstance(info) + if (accountInfo == null) { + Timber.i("Failed to parse account info: $info") + continue + } + + container[account] = accountInfo + } + Timber.d("token account infos handled") + Single.just(container) + } + AccountService.GetTokenAccountInfosResponse.Result.NOT_FOUND -> { + Timber.i("Account not found for owner: ${owner.getPublicKeyBase58()}") + Single.error(FetchAccountInfosException.NotFoundException()) + } + else -> { + Timber.i("Unknown exception") + Single.error(FetchAccountInfosException.UnknownException()) + } + } + } + } + + sealed class FetchAccountInfosException : Exception() { + class NotFoundException : FetchAccountInfosException() + class UnknownException : FetchAccountInfosException() + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/BalanceRepository.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/BalanceRepository.kt new file mode 100644 index 000000000..53600bec6 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/BalanceRepository.kt @@ -0,0 +1,22 @@ +package com.getcode.network.repository + + +import kotlinx.coroutines.flow.MutableStateFlow +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BalanceRepository @Inject constructor() { + + val balanceFlow = MutableStateFlow(-1.0) + + fun setBalance(balance: Double) { + balanceFlow.value = balance + } + + fun clearBalance() { + balanceFlow.value = 0.0 + } + +} diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/Extensions.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/Extensions.kt new file mode 100644 index 000000000..6fc94f94f --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/Extensions.kt @@ -0,0 +1,42 @@ +package com.getcode.network.repository + +import com.codeinc.gen.common.v1.CodeModel +import com.getcode.ed25519.Ed25519 +import com.getcode.utils.toByteString +import com.google.protobuf.MessageLite +import java.io.ByteArrayOutputStream + +fun isMock() = false + +fun ByteArray.toUserId(): CodeModel.UserId { + return CodeModel.UserId.newBuilder().setValue(this.toByteString()).build() +} + +fun String.toPhoneNumber(): CodeModel.PhoneNumber { + return CodeModel.PhoneNumber.newBuilder().setValue(this).build() +} + +fun List.toSolanaAccount(): CodeModel.SolanaAccountId { + return CodeModel.SolanaAccountId.newBuilder().setValue(this.toByteArray().toByteString()) + .build() +} + +fun ByteArray.toSolanaAccount(): CodeModel.SolanaAccountId { + return CodeModel.SolanaAccountId.newBuilder().setValue(this.toByteString()) + .build() +} + +fun ByteArray.toSignature(): CodeModel.Signature { + return CodeModel.Signature.newBuilder().setValue(this.toByteString()) + .build() +} + +fun com.getcode.solana.keys.PublicKey.toIntentId(): CodeModel.IntentId { + return CodeModel.IntentId.newBuilder().setValue(this.byteArray.toByteString()).build() +} + +fun MessageLite.Builder.sign(owner: Ed25519.KeyPair): CodeModel.Signature { + val bos = ByteArrayOutputStream() + this.buildPartial().writeTo(bos) + return Ed25519.sign(bos.toByteArray(), owner).toSignature() +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/MessagingRepository.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/MessagingRepository.kt new file mode 100644 index 000000000..d56039df4 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/MessagingRepository.kt @@ -0,0 +1,315 @@ +package com.getcode.network.repository + +import com.codeinc.gen.common.v1.CodeModel as Model +import com.codeinc.gen.messaging.v1.CodeMessagingService as MessagingService +import com.codeinc.gen.messaging.v1.CodeMessagingService.ClientRejectedLogin +import com.codeinc.gen.messaging.v1.CodeMessagingService.CodeScanned +import com.codeinc.gen.messaging.v1.CodeMessagingService.PollMessagesRequest +import com.codeinc.gen.messaging.v1.CodeMessagingService.RendezvousKey +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import com.getcode.ed25519.Ed25519 +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.model.Domain +import com.getcode.model.Fiat +import com.getcode.model.PaymentRequest +import com.getcode.model.StreamMessage +import com.getcode.model.toPublicKey +import com.getcode.network.api.MessagingApi +import com.getcode.services.network.core.INFINITE_STREAM_TIMEOUT +import com.getcode.services.network.core.NetworkOracle +import com.getcode.utils.ErrorUtils +import com.getcode.utils.getPublicKeyBase58 +import com.getcode.utils.hexEncodedString +import com.google.protobuf.ByteString +import com.google.protobuf.Timestamp +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.reactive.asFlow +import timber.log.Timber +import java.io.ByteArrayOutputStream +import javax.inject.Inject + +private const val TAG = "MessagingRepository" + +class MessagingRepository @Inject constructor( + private val messagingApi: MessagingApi, + private val networkOracle: NetworkOracle, +) { + + fun openMessageStream( + rendezvousKeyPair: KeyPair, + ): Flowable { + Timber.i("openMessageStream") + + val request = MessagingService.OpenMessageStreamRequest.newBuilder() + .setRendezvousKey( + RendezvousKey.newBuilder().setValue( + ByteString.copyFrom(rendezvousKeyPair.publicKeyBytes) + ) + ) + .build() + + return messagingApi.openMessageStream(request) + .let { networkOracle.managedRequest(it, INFINITE_STREAM_TIMEOUT) } + .map { + Timber.d("message stream response received") + it.messagesList + .filter { message -> + message.kindCase == MessagingService.Message.KindCase.REQUEST_TO_GRAB_BILL + } + } + .doOnNext { messagesList -> + if (messagesList.isEmpty()) { + return@doOnNext + } + ackMessages(rendezvousKeyPair, messagesList.map { it.id }) + .subscribe({ Timber.d("acked") }, ErrorUtils::handleError) + } + .filter { it.isNotEmpty() } + .map { messagesList -> + messagesList.map { message -> + val account = + message.requestToGrabBill.requestorAccount.value.toByteArray().toPublicKey() + val signature = + com.getcode.solana.keys.Signature( + message.sendMessageRequestSignature.value.toByteArray().toList() + ) + PaymentRequest(account, signature) + }.first() + } + .retry(10L) { + it.printStackTrace() + true + } + .subscribeOn(Schedulers.computation()) + } + + private fun ackMessages( + rendezvousKeyPair: KeyPair, + messageIds: List + ): Completable { + val request = MessagingService.AckMessagesRequest.newBuilder() + .addAllMessageIds(messageIds) + .setRendezvousKey( + RendezvousKey.newBuilder().setValue( + ByteString.copyFrom(rendezvousKeyPair.publicKeyBytes) + ) + ) + .build() + + return networkOracle.managedRequest(messagingApi.ackMessages(request)) + .flatMapCompletable { + if (it.result == MessagingService.AckMesssagesResponse.Result.OK) { + Timber.i("ackMessages: Result.OK: $messageIds") + Completable.complete() + } else { + Completable.error(RuntimeException("Failed to ack message with ids: $messageIds")) + } + } + } + + fun verifyRequestToGrabBill( + destination: com.getcode.solana.keys.PublicKey, + rendezvousKey: KeyPair, + signature: com.getcode.solana.keys.Signature + ): Boolean { + val messageData = sendRequestToGrabBill(destination = destination).build().toByteArray() + return rendezvousKey.verify(signature.byteArray, messageData) + } + + suspend fun sendRequestToLogin( + domain: Domain, + verifier: KeyPair, + rendezvous: KeyPair, + ): Result { + val message = requestToLogin(domain, verifier, rendezvous) + return sendRendezvousMessage(message, rendezvous) + } + + fun sendRequestToGrabBill( + destination: ByteArray, + rendezvousKeyPair: KeyPair, + ): Flowable { + val requestor = destination.toSolanaAccount() + val paymentRequest = MessagingService.RequestToGrabBill.newBuilder() + .setRequestorAccount(requestor) + val message = MessagingService.Message.newBuilder() + .setRequestToGrabBill(paymentRequest) + + return sendRendezvousMessageFlowable(message, rendezvousKeyPair) + .doOnEach { + Timber.i("sendRequestForPayment: result: ${it.value?.result}") + } + } + + suspend fun sendRequestToReceiveBill( + destination: com.getcode.solana.keys.PublicKey, + fiat: Fiat, + rendezvous: KeyPair + ): Result { + val message = MessagingService.Message.newBuilder() + .setRequestToReceiveBill( + MessagingService.RequestToReceiveBill.newBuilder() + .setRequestorAccount(destination.byteArray.toSolanaAccount()) + .setPartial( + TransactionService.ExchangeDataWithoutRate.newBuilder() + .setCurrency(fiat.currency.name) + .setNativeAmount(fiat.amount) + ) + .build() + ) + + return sendRendezvousMessage(message, rendezvous) + } + + fun fetchMessages(rendezvous: KeyPair): Result> { + val request = PollMessagesRequest.newBuilder() + .setRendezvousKey( + RendezvousKey.newBuilder() + .setValue(ByteString.copyFrom(rendezvous.publicKeyBytes)) + ).apply { setSignature(sign(rendezvous)) } + .build() + + return networkOracle.managedRequest(messagingApi.pollMessages(request)) + .observeOn(Schedulers.io()) + .map { response -> + Timber.d("response=${response.messagesList}") + response.messagesList.mapNotNull { m -> StreamMessage.getInstance(m) } + }.firstOrError().blockingGet().runCatching { this } + } + + suspend fun codeScanned(rendezvous: KeyPair): Result { + val message = MessagingService.Message.newBuilder() + .setCodeScanned( + CodeScanned.newBuilder() + .setTimestamp( + Timestamp.newBuilder().setSeconds(System.currentTimeMillis() / 1_000) + ) + ) + + return sendRendezvousMessage(message, rendezvous) + } + + suspend fun rejectPayment(rendezvous: KeyPair): Result { + val rejection = MessagingService.ClientRejectedPayment.newBuilder() + .setIntentId(com.getcode.solana.keys.PublicKey.fromBase58(rendezvous.getPublicKeyBase58()).toIntentId()) + .build() + + val message = MessagingService.Message.newBuilder() + .setClientRejectedPayment(rejection) + + return sendRendezvousMessage(message, rendezvous) + } + + suspend fun rejectLogin(rendezvous: KeyPair): Result { + val message = MessagingService.Message + .newBuilder() + .setClientRejectedLogin( + ClientRejectedLogin.newBuilder() + .setTimestamp( + Timestamp.newBuilder().setSeconds(System.currentTimeMillis() / 1_000) + ) + ) + + return sendRendezvousMessage(message, rendezvous) + } + + private fun sendRequestToGrabBill(destination: com.getcode.solana.keys.PublicKey): MessagingService.Message.Builder { + return MessagingService.Message + .newBuilder() + .setRequestToGrabBill( + MessagingService.RequestToGrabBill + .newBuilder() + .setRequestorAccount(destination.bytes.toSolanaAccount()) + ) + } + + private fun requestToLogin( + domain: Domain, + verifier: KeyPair, + rendezvous: KeyPair + ): MessagingService.Message.Builder { + return MessagingService.Message + .newBuilder() + .setRequestToLogin( + MessagingService.RequestToLogin + .newBuilder() + .setDomain( + Model.Domain.newBuilder() + .setValue(domain.relationshipHost) + ) + .setRendezvousKey( + RendezvousKey.newBuilder() + .setValue(ByteString.copyFrom(rendezvous.publicKeyBytes)) + ).setVerifier(verifier.publicKeyBytes.toSolanaAccount()) + .let { + val bos = ByteArrayOutputStream() + it.buildPartial().writeTo(bos) + it.setSignature(Ed25519.sign(bos.toByteArray(), rendezvous).toSignature()) + } + ) + } + + private suspend fun sendRendezvousMessage( + message: MessagingService.Message.Builder, + rendezvous: KeyPair + ): Result { + val signature = ByteArrayOutputStream().let { + message.buildPartial().writeTo(it) + val signed = Ed25519.sign(it.toByteArray(), rendezvous) + Model.Signature.newBuilder().setValue(ByteString.copyFrom(signed)) + } + + val request = MessagingService.SendMessageRequest.newBuilder() + .setMessage(message) + .setRendezvousKey( + RendezvousKey.newBuilder().setValue( + ByteString.copyFrom(rendezvous.publicKeyBytes) + ) + ) + .setSignature(signature) + .build() + + return runCatching { + messagingApi.sendMessage(request) + .let { networkOracle.managedRequest(it) } + .asFlow() + .firstOrNull() ?: throw IllegalArgumentException() + }.onSuccess { + Timber.i( + "message sent: ${ + it.messageId.value.toList().hexEncodedString() + }: result: ${it.result}" + ) + }.onFailure { + ErrorUtils.handleError(it) + Timber.e(t = it, message = "Failed to send rendezvous message.") + } + } + + private fun sendRendezvousMessageFlowable( + message: MessagingService.Message.Builder, + rendezvous: KeyPair + ): Flowable { + val signature = ByteArrayOutputStream().let { + message.buildPartial().writeTo(it) + val signed = Ed25519.sign(it.toByteArray(), rendezvous) + Model.Signature.newBuilder().setValue(ByteString.copyFrom(signed)) + } + + val request = MessagingService.SendMessageRequest.newBuilder() + .setMessage(message) + .setRendezvousKey( + RendezvousKey.newBuilder().setValue( + ByteString.copyFrom(rendezvous.publicKeyBytes) + ) + ) + .setSignature(signature) + .build() + + return messagingApi.sendMessage(request) + .let { networkOracle.managedRequest(it) } + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/PaymentRepository.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/PaymentRepository.kt new file mode 100644 index 000000000..9212d7f7d --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/PaymentRepository.kt @@ -0,0 +1,55 @@ +package com.getcode.network.repository + +import com.getcode.model.ID +import com.getcode.model.KinAmount +import com.getcode.network.BalanceController +import com.getcode.network.client.Client +import com.getcode.network.client.fetchLimits +import com.getcode.network.client.publicPayment +import com.getcode.services.model.ExtendedMetadata +import com.getcode.solana.keys.PublicKey +import com.getcode.utils.ErrorUtils +import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject + + +class PaymentRepository @Inject constructor( + private val userManager: UserManager, + private val client: Client, + private val balanceController: BalanceController, +) : CoroutineScope by CoroutineScope(Dispatchers.IO) { + + suspend fun payPublicly( + amount: KinAmount, + destination: PublicKey, + extendedMetadata: ExtendedMetadata + ): ID { + val organizer = userManager.organizer ?: throw PaymentError.OrganizerNotFound() + val currentBalance = balanceController.rawBalance + if (amount.kin.toKinValueDouble() > currentBalance) throw PaymentError.InsufficientBalance() + return client.publicPayment( + amount = amount, + organizer = organizer, + destination = destination, + extendedMetadata = extendedMetadata + ).onSuccess { + balanceController.fetchBalance() + client.fetchLimits(isForce = true).observeOn(Schedulers.io()).subscribe() + }.onFailure { + ErrorUtils.handleError(it) + }.getOrThrow() + } +} + +sealed interface PaymentError { + val message: String? + + data class OrganizerNotFound(override val message: String? = "Organizer not found") : + PaymentError, Throwable(message) + + data class InsufficientBalance(override val message: String? = "Insufficient balance for payment") : + PaymentError, Throwable(message) +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/ReceiveTransactionRepository.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/ReceiveTransactionRepository.kt new file mode 100644 index 000000000..56056bd8b --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/ReceiveTransactionRepository.kt @@ -0,0 +1,39 @@ +package com.getcode.network.repository + +import com.codeinc.gen.messaging.v1.CodeMessagingService +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.model.IntentMetadata +import com.getcode.model.toPublicKey +import com.getcode.network.client.Client +import com.getcode.network.client.pollIntentMetadata +import com.getcode.solana.organizer.Organizer +import com.getcode.utils.ErrorUtils +import io.reactivex.rxjava3.core.Flowable +import kotlinx.coroutines.rx3.asFlowable +import javax.inject.Inject + +class ReceiveTransactionRepository @Inject constructor( + private val messagingRepository: MessagingRepository, + private val client: Client +) { + fun start(organizer: Organizer, rendezvous: KeyPair, debug: Boolean = false): Flowable { + return messagingRepository.sendRequestToGrabBill( + destination = organizer.incomingVault.byteArray, + rendezvousKeyPair = rendezvous + ) + .flatMap { paymentRequestResponse -> + if (paymentRequestResponse.result != CodeMessagingService.SendMessageResponse.Result.OK) { + Flowable.error(Exception("Error: ${paymentRequestResponse.result.name}")) + } else { + client.pollIntentMetadata( + owner = organizer.ownerKeyPair, + intentId = rendezvous.publicKeyBytes.toPublicKey(), + debugLogs = debug, + ).asFlowable() + } + } + .doOnError { + ErrorUtils.handleError(it) + } + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/SendTransactionRepository.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/SendTransactionRepository.kt new file mode 100644 index 000000000..a39bc5deb --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/SendTransactionRepository.kt @@ -0,0 +1,138 @@ +package com.getcode.network.repository + +import com.getcode.ed25519.Ed25519 +import com.getcode.services.model.CodePayload +import com.getcode.model.IntentMetadata +import com.getcode.model.Kin +import com.getcode.model.KinAmount +import com.getcode.services.model.Kind +import com.getcode.model.toPublicKey +import com.getcode.network.client.Client +import com.getcode.network.client.pollIntentMetadata +import com.getcode.network.client.transfer +import com.getcode.services.analytics.AnalyticsService +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.organizer.Organizer +import com.getcode.services.utils.nonce +import io.reactivex.rxjava3.core.Flowable +import kotlinx.coroutines.rx3.asFlowable +import javax.inject.Inject + + +class SendTransactionRepository @Inject constructor( + private val messagingRepository: MessagingRepository, + private val analyticsManager: AnalyticsService, + private val client: Client, +) { + private lateinit var amount: KinAmount + private lateinit var organizer: Organizer + private lateinit var owner: Ed25519.KeyPair + private lateinit var payload: CodePayload + private lateinit var payloadData: List + + private lateinit var rendezvousKey: Ed25519.KeyPair + private var receivingAccount: PublicKey? = null + + fun init(amount: KinAmount, organizer: Organizer, owner: Ed25519.KeyPair): List { + this.amount = amount + this.organizer = organizer + this.owner = owner + + this.payload = CodePayload( + kind = Kind.Cash, + value = amount.kin, + nonce = nonce, + ) + + this.payloadData = payload.codeData.toList() + this.rendezvousKey = payload.rendezvous + this.receivingAccount = null + + return payloadData + } + + fun startTransaction(): Flowable { + return messagingRepository.openMessageStream(rendezvousKey) + .firstOrError() + .flatMapPublisher { paymentRequest -> + // 1. Validate that destination hasn't been tampered with by + // verifying the signature matches one that has been signed + // with the rendezvous key. + + val isValid = messagingRepository.verifyRequestToGrabBill( + destination = paymentRequest.account, + rendezvousKey = rendezvousKey, + signature = paymentRequest.signature + ) + + if (!isValid) { + // TODO: analytics +// analyticsManager.transfer( +// amount = amount, +// successful = false +// ) + + Flowable.error(SendTransactionException.DestinationSignatureInvalidException()) + } else { + // TODO: analytics +// analyticsManager.transfer( +// amount = amount, +// successful = true +// ) + + // 2. Send the funds to destination + sendFundsAndPoll(organizer, paymentRequest.account) + } + } + .doOnError { + // TODO: analytics +// analyticsManager.transfer( +// amount = amount, +// successful = false +// ) + } + } + + private fun sendFundsAndPoll( + organizer: Organizer, + destination: PublicKey + ): Flowable { + if (receivingAccount == destination) { + // Ensure that we're processing one, and only one + // transaction for each instance of SendTransaction. + // Completion will be called by the first invocation + // of this function. + return Flowable.error(SendTransactionException.DuplicateTransferException()) + } + + receivingAccount = destination + + // It's possible that we have funds sitting in the incoming + // account counting towards the active balance. If we don't + // deposit the funds and the transaction size exceeds what's + // in the buckets, the send will fail. + return client.transfer( + amount = amount.copy(kin = amount.kin.toKinTruncating()), + fee = Kin.fromKin(0), + additionalFees = emptyList(), + organizer = organizer, + rendezvousKey = rendezvousKey.publicKeyBytes.toPublicKey(), + destination = destination, + isWithdrawal = false + ) + .andThen( + client.pollIntentMetadata( + owner = organizer.ownerKeyPair, + intentId = rendezvousKey.publicKeyBytes.toPublicKey() + ).asFlowable() + ) + } + + fun getAmount() = amount + fun getRendezvous() = rendezvousKey + + sealed class SendTransactionException : Exception() { + class DuplicateTransferException : SendTransactionException() + class DestinationSignatureInvalidException : SendTransactionException() + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/StatusRepository.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/StatusRepository.kt new file mode 100644 index 000000000..0fbfc40e3 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/StatusRepository.kt @@ -0,0 +1,29 @@ +package com.getcode.network.repository + +import io.reactivex.rxjava3.core.Single +import okhttp3.Call +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.json.JSONObject + +class StatusRepository { + fun getIsUpgradeRequired(currentVersionCode: Int): Single { + val request: Request = Request.Builder() + .url("https://app.getcode.com/status") + .build() + val call: Call = OkHttpClient().newCall(request) + + return Single.create { + val response: Response = call.execute() + if (!response.isSuccessful) { + it.onError(Exception()) + return@create + } + val json = JSONObject(response.body?.string().orEmpty()) + val minimumVersion = json.getInt("minimumClientVersion") + val isUpgradeRequired = currentVersionCode < minimumVersion + it.onSuccess(isUpgradeRequired) + } + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/TransactionRepository.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/TransactionRepository.kt new file mode 100644 index 000000000..283e61a0e --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/TransactionRepository.kt @@ -0,0 +1,946 @@ +package com.getcode.network.repository + +import android.annotation.SuppressLint +import com.codeinc.gen.transaction.v2.CodeTransactionService.DeclareFiatOnrampPurchaseAttemptResponse +import com.codeinc.gen.transaction.v2.CodeTransactionService.ExchangeDataWithoutRate +import com.codeinc.gen.transaction.v2.CodeTransactionService.SubmitIntentRequest +import com.codeinc.gen.transaction.v2.CodeTransactionService.SubmitIntentResponse +import com.codeinc.gen.transaction.v2.CodeTransactionService.SubmitIntentResponse.ResponseCase.ERROR +import com.codeinc.gen.transaction.v2.CodeTransactionService.SubmitIntentResponse.ResponseCase.SERVER_PARAMETERS +import com.codeinc.gen.transaction.v2.CodeTransactionService.SubmitIntentResponse.ResponseCase.SUCCESS +import com.getcode.crypt.MnemonicPhrase +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.model.BuyLimit +import com.getcode.model.CurrencyCode +import com.getcode.model.Domain +import com.getcode.model.Fee +import com.getcode.model.IntentMetadata +import com.getcode.model.Kin +import com.getcode.model.KinAmount +import com.getcode.model.Limits +import com.getcode.model.Rate +import com.getcode.model.SendLimit +import com.getcode.model.UpgradeableIntent +import com.getcode.model.UpgradeablePrivateAction +import com.getcode.model.extensions.newInstance +import com.getcode.model.fromProtoExchangeData +import com.getcode.model.intents.ActionGroup +import com.getcode.model.intents.IntentCreateAccounts +import com.getcode.model.intents.IntentDeposit +import com.getcode.model.intents.IntentEstablishRelationship +import com.getcode.model.intents.IntentPrivateTransfer +import com.getcode.model.intents.IntentPublicPayment +import com.getcode.model.intents.IntentPublicTransfer +import com.getcode.model.intents.IntentReceive +import com.getcode.model.intents.IntentRemoteReceive +import com.getcode.model.intents.IntentRemoteSend +import com.getcode.model.intents.IntentType +import com.getcode.model.intents.IntentUpgradePrivacy +import com.getcode.model.intents.ServerParameter +import com.getcode.network.api.TransactionApiV2 +import com.getcode.services.model.ExtendedMetadata +import com.getcode.services.observers.BidirectionalStreamReference +import com.getcode.solana.keys.AssociatedTokenAccount +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.base58 +import com.getcode.solana.organizer.AccountType +import com.getcode.solana.organizer.GiftCardAccount +import com.getcode.solana.organizer.Organizer +import com.getcode.solana.organizer.Relationship +import com.getcode.utils.ErrorUtils +import com.getcode.utils.TraceType +import com.getcode.utils.base58 +import com.getcode.utils.bytes +import com.getcode.utils.toByteString +import com.getcode.utils.trace +import com.google.protobuf.Timestamp +import io.grpc.stub.StreamObserver +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.subjects.SingleSubject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import timber.log.Timber +import xyz.flipchat.services.payments.BuildConfig +import java.util.UUID +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.time.Duration.Companion.seconds +import com.codeinc.gen.common.v1.CodeModel as Model +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService + +private const val TAG = "TransactionRepositoryV2" + +typealias BidirectionalIntentStream = BidirectionalStreamReference + +@Singleton +class TransactionRepository @Inject constructor( + val transactionApi: TransactionApiV2, +) : CoroutineScope by CoroutineScope(Dispatchers.IO) { + + var limits: Limits? = null + private set + + val areLimitsState: Boolean + get() = limits == null || limits?.isStale == true + + private var lastSwap: Long = 0L + + var maxDeposit: Kin = Kin.fromKin(0) + + fun setMaximumDeposit(deposit: Kin) { + maxDeposit = deposit + } + + fun buyLimitFor(currencyCode: CurrencyCode): BuyLimit? { + return limits?.buyLimitFor(currencyCode) + } + + fun sendLimitFor(currencyCode: CurrencyCode): SendLimit? { + return limits?.sendLimitFor(currencyCode) + } + + fun hasAvailableTransactionLimit(amount: KinAmount): Boolean { + return (sendLimitFor(amount.rate.currency)?.nextTransaction ?: 0.0) >= amount.fiat + } + + fun hasAvailableDailyLimit(): Boolean { + return (sendLimitFor(currencyCode = CurrencyCode.USD)?.nextTransaction ?: 0.0) > 0 + } + + private fun setLimits(limits: Limits) { + trace("updating limits") + this.limits = limits + } + + fun clear() { + Timber.d("clearing transactions") + maxDeposit = Kin.fromKin(0) + } + + fun createAccounts(organizer: Organizer): Single { + if (isMock()) return Single.just( + IntentCreateAccounts( + id = PublicKey(bytes = listOf()), + actionGroup = ActionGroup(), + organizer = organizer + ) as IntentType + ).delay(1, TimeUnit.SECONDS) + + val createAccounts = IntentCreateAccounts.newInstance(organizer) + + return submit(createAccounts, organizer.tray.owner.getCluster().authority.keyPair, null) + } + + suspend fun publicPayment( + amount: KinAmount, + organizer: Organizer, + destination: PublicKey, + extendedMetadata: ExtendedMetadata? = null, + ): Result { + val intent = IntentPublicPayment.newInstance( + organizer = organizer, + destination = destination, + amount = amount.copy(kin = amount.kin.toKinTruncating()), + source = AccountType.Primary, + extendedMetadata = extendedMetadata + ) + + return submitForResult( + intent = intent, + owner = organizer.tray.owner.getCluster().authority.keyPair + ) + } + + fun transfer( + amount: KinAmount, + fee: Kin, + additionalFees: List, + organizer: Organizer, + rendezvousKey: PublicKey, + destination: PublicKey, + isWithdrawal: Boolean, + metadata: ExtendedMetadata? = null, + ): Single { + if (isMock()) return Single.just( + IntentPrivateTransfer( + id = PublicKey(bytes = listOf()), + actionGroup = ActionGroup(), + organizer = organizer, + destination = destination, + grossAmount = amount, + netAmount = amount, + fee = fee, + additionalFees = emptyList(), + resultTray = organizer.tray, + isWithdrawal = isWithdrawal, + metadata = metadata + ) as IntentType + ) + .delay(1, TimeUnit.SECONDS) + + val intent = IntentPrivateTransfer.newInstance( + rendezvousKey = rendezvousKey, + organizer = organizer, + destination = destination, + amount = amount.copy(kin = amount.kin.toKinTruncating()), + fee = fee, + additionalFees = additionalFees, + isWithdrawal = isWithdrawal, + metadata = metadata + ) + + return submit(intent = intent, owner = organizer.tray.owner.getCluster().authority.keyPair) + } + + fun receiveFromIncoming( + amount: Kin, + organizer: Organizer + ): Single { + val intent = IntentReceive.newInstance( + organizer = organizer, + amount = amount.toKinTruncating() + ) + return submit(intent = intent, owner = organizer.tray.owner.getCluster().authority.keyPair) + } + + suspend fun swapIfNeeded(organizer: Organizer) { + // We need to check and see if the USDC account has a balance, + // if so, we'll initiate a swap to Kin. The nuance here is that + // the balance of the USDC account is reported as `Kin`, where the + // quarks represent the lamport balance of the account. + val info = organizer.info(AccountType.Swap) ?: return + if (info.balance.quarks <= 0) return + + val timeout = 45.seconds + + // Ensure that it's been at least `timeout` seconds since we try + // another swap if one is already in-flight. + if (System.currentTimeMillis() - lastSwap < timeout.inWholeMilliseconds) return + + lastSwap = System.currentTimeMillis() + + initiateSwap(organizer) + .onFailure { ErrorUtils.handleError(it) } + } + + fun receiveFromPrimary(amount: Kin, organizer: Organizer): Single { + val intent = IntentDeposit.newInstance( + source = AccountType.Primary, + organizer = organizer, + amount = amount.toKinTruncating() + ) + return submit(intent = intent, owner = organizer.tray.owner.getCluster().authority.keyPair) + } + + fun receiveFromRelationship( + relationship: Relationship, + organizer: Organizer + ): Single { + val intent = IntentPublicTransfer.newInstance( + source = AccountType.Relationship(relationship.domain), + organizer = organizer, + amount = KinAmount.newInstance(relationship.partialBalance, Rate.oneToOne), + destination = IntentPublicTransfer.Destination.Local(AccountType.Primary) + ) + return submit(intent, owner = organizer.tray.owner.getCluster().authority.keyPair) + } + + fun withdraw( + amount: KinAmount, + organizer: Organizer, + destination: PublicKey + ): Single { + val intent = IntentPublicTransfer.newInstance( + organizer = organizer, + amount = amount, + destination = IntentPublicTransfer.Destination.External(destination), + source = AccountType.Primary, + ) + + return submit(intent = intent, owner = organizer.tray.owner.getCluster().authority.keyPair) + } + + fun upgradePrivacy( + mnemonic: MnemonicPhrase, + upgradeableIntent: UpgradeableIntent + ): Single { + val intent = IntentUpgradePrivacy.newInstance( + mnemonic = mnemonic, + upgradeableIntent = upgradeableIntent + ) + return submit(intent, owner = mnemonic.getSolanaKeyPair()) + } + + fun sendRemotely( + amount: KinAmount, + organizer: Organizer, + rendezvousKey: PublicKey, + giftCard: GiftCardAccount + ): Single { + val intent = IntentRemoteSend.newInstance( + rendezvousKey = rendezvousKey, + organizer = organizer, + giftCard = giftCard, + amount = amount, + ) + return submit(intent, owner = organizer.tray.owner.getCluster().authority.keyPair) + } + + fun receiveRemotely( + amount: Kin, + organizer: Organizer, + giftCard: GiftCardAccount, + isVoiding: Boolean + ): Single { + val intent = IntentRemoteReceive.newInstance( + organizer = organizer, + giftCard = giftCard, + amount = amount, + isVoidingGiftCard = isVoiding + ) + return submit(intent, owner = organizer.tray.owner.getCluster().authority.keyPair) + } + + private suspend fun submitForResult( + intent: IntentType, + owner: KeyPair, + deviceToken: String? = null + ): Result = suspendCancellableCoroutine { cont -> + val reference = BidirectionalIntentStream(this) + + // Intentionally creates a retain-cycle using closures to ensure that we have + // a strong reference to the stream at all times. Doing so ensures that the + // callers don't have to manage the pointer to this stream and keep it alive + reference.retain() + + reference.stream = + transactionApi.submitIntent(object : StreamObserver { + override fun onNext(value: SubmitIntentResponse?) { + when (value?.responseCase) { + // 2. Upon successful submission of intent action the server will + // respond with parameters that we'll need to apply to the intent + // before crafting and signing the transactions. + SERVER_PARAMETERS -> { + try { + intent.apply( + value.serverParameters.serverParametersList + .map { p -> ServerParameter.newInstance(p) } + ) + + Timber.i( + "Received ${value.serverParameters.serverParametersList.size} parameters. Submitting signatures..." + ) + + val submitSignatures = intent.requestToSubmitSignatures() + reference.stream?.onNext(submitSignatures) + } catch (e: Exception) { + if (BuildConfig.DEBUG) { + e.printStackTrace() + } + Timber.i( + "Received ${value.serverParameters.serverParametersList.size} parameters but failed to apply them: ${e.javaClass.simpleName} ${e.message})" + ) + reference.stream?.onError( + ErrorSubmitIntentException( + ErrorSubmitIntent.Unknown + ) + ) + } + } + // 3. If submitted transaction signatures are valid and match + // the server, we'll receive a success for the submitted intent. + SUCCESS -> { + Timber.i("Success.") + reference.stream?.onCompleted() + cont.resume(Result.success(intent)) + } + // 3. If the submitted transaction signatures don't match, the + // intent is considered failed. Something must have gone wrong + // on the transaction creation or signing on our side. + ERROR -> { + val errors = mutableListOf() + + value.error.errorDetailsList.forEach { error -> + when (error.typeCase) { + TransactionService.ErrorDetails.TypeCase.REASON_STRING -> { + errors.add("Reason: ${error.reasonString}") + } + + TransactionService.ErrorDetails.TypeCase.INVALID_SIGNATURE -> { + val expectedVixn = + error.invalidSignature.expectedVixnHash.value.toByteArray() + val produced = intent.vixnHash() + errors.add("Signature mismatch: :: VIXN:: expected=${expectedVixn.base58}, produced=${produced.base58()}") + } + + TransactionService.ErrorDetails.TypeCase.DENIED -> { + errors.add("Denied: ${error.denied.reason}") + } + + else -> Unit + } + } + + trace( + "Error: ${errors.joinToString("\n")}", + type = TraceType.Error + ) + + reference.stream?.onCompleted() + cont.resume( + Result.failure( + ErrorSubmitIntentException( + ErrorSubmitIntent.invoke( + value.error + ), + ) + ) + ) + } + + else -> { + Timber.i("Else case. ${value?.responseCase}") + reference.stream?.onCompleted() + cont.resume(Result.failure(ErrorSubmitIntentException(ErrorSubmitIntent.Unknown))) + } + } + } + + override fun onError(t: Throwable?) { + Timber.i("onError: " + t?.message.orEmpty()) + t?.let { + ErrorUtils.handleError(it) + } + cont.resume( + Result.failure( + ErrorSubmitSwapIntentException(ErrorSubmitSwapIntent.Unknown, t) + ) + ) + } + + override fun onCompleted() { + Timber.i("onCompleted") + } + }) + + + // 1. Send `submitActions` request with + // actions generated by the intent + reference.stream?.onNext(intent.requestToSubmitActions(owner, deviceToken)) + } + + private fun submit( + intent: IntentType, + owner: KeyPair, + deviceToken: String? = null + ): Single { + Timber.i("Submit ${intent.javaClass.simpleName}") + val subject = SingleSubject.create() + + var serverMessageStream: StreamObserver? = null + val serverResponse = object : StreamObserver { + override fun onNext(value: SubmitIntentResponse?) { + Timber.i("Received: " + value?.responseCase?.name.orEmpty()) + + when (value?.responseCase) { + // 2. Upon successful submission of intent action the server will + // respond with parameters that we'll need to apply to the intent + // before crafting and signing the transactions. + SERVER_PARAMETERS -> { + try { + intent.apply( + value.serverParameters.serverParametersList + .map { p -> ServerParameter.newInstance(p) } + ) + + Timber.i( + "Received ${value.serverParameters.serverParametersList.size} parameters. Submitting signatures..." + ) + + val submitSignatures = intent.requestToSubmitSignatures() + serverMessageStream?.onNext(submitSignatures) + } catch (e: Exception) { + if (BuildConfig.DEBUG) { + e.printStackTrace() + } + Timber.i( + "Received ${value.serverParameters.serverParametersList.size} parameters but failed to apply them: ${e.javaClass.simpleName} ${e.message})" + ) + subject.onError(ErrorSubmitIntentException(ErrorSubmitIntent.Unknown)) + } + } + // 3. If submitted transaction signatures are valid and match + // the server, we'll receive a success for the submitted intent. + SUCCESS -> { + Timber.i("Success.") + serverMessageStream?.onCompleted() + subject.onSuccess(intent) + } + // 3. If the submitted transaction signatures don't match, the + // intent is considered failed. Something must have gone wrong + // on the transaction creation or signing on our side. + ERROR -> { + val errors = mutableListOf() + + value.error.errorDetailsList.forEach { error -> + when (error.typeCase) { + TransactionService.ErrorDetails.TypeCase.REASON_STRING -> { + errors.add("Reason: ${error.reasonString}") + } + + TransactionService.ErrorDetails.TypeCase.INVALID_SIGNATURE -> { + val expectedVixn = + error.invalidSignature.expectedVixnHash.value.toByteArray() + val produced = intent.vixnHash() + errors.add("Signature mismatch: :: VIXN:: expected=${expectedVixn.base58}, produced=${produced.base58()}") + } + + TransactionService.ErrorDetails.TypeCase.DENIED -> { + errors.add("Denied: ${error.denied.reason}") + } + + else -> Unit + } + } + + trace( + "Error: ${errors.joinToString("\n")}", + type = TraceType.Error + ) + + serverMessageStream?.onCompleted() + subject.onError( + ErrorSubmitIntentException( + ErrorSubmitIntent.invoke(value.error), + ) + ) + } + + else -> { + Timber.i("Else case. ${value?.responseCase}") + serverMessageStream?.onCompleted() + subject.onError(ErrorSubmitIntentException(ErrorSubmitIntent.Unknown)) + } + } + } + + override fun onError(t: Throwable?) { + Timber.i("onError: " + t?.message.orEmpty()) + subject.onError(ErrorSubmitIntentException(ErrorSubmitIntent.Unknown, t)) + } + + override fun onCompleted() { + Timber.i("onCompleted") + } + } + serverMessageStream = transactionApi.submitIntent(serverResponse) + + // 1. Send `submitActions` request with + // actions generated by the intent + serverMessageStream.onNext(intent.requestToSubmitActions(owner, deviceToken)) + + return subject + } + + fun establishRelationshipSingle( + organizer: Organizer, + domain: Domain, + ): Single { + val intent = IntentEstablishRelationship.newInstance(organizer, domain) + + return submit(intent = intent, organizer.tray.owner.getCluster().authority.keyPair) + .map { intent } + .doOnSuccess { Timber.d("established relationship") } + .doOnError { Timber.e(t = it, message = "failed to establish relationship") } + } + + @SuppressLint("CheckResult") + suspend fun establishRelationship( + organizer: Organizer, + domain: Domain + ): Result { + val intent = IntentEstablishRelationship.newInstance(organizer, domain) + + return runCatching { + submit(intent = intent, organizer.tray.owner.getCluster().authority.keyPair) + .map { intent } + .toFlowable() + .asFlow() + .firstOrNull() ?: throw IllegalArgumentException() + }.onSuccess { + Timber.d("established relationship") + }.onFailure { + ErrorUtils.handleError(it) + Timber.e(t = it, message = "failed to establish relationship") + } + } + + suspend fun declareFiatPurchase(owner: KeyPair, amount: KinAmount, nonce: UUID): Result { + val request = TransactionService.DeclareFiatOnrampPurchaseAttemptRequest.newBuilder() + .setOwner(owner.publicKeyBytes.toSolanaAccount()) + .setPurchaseAmount( + ExchangeDataWithoutRate.newBuilder() + .setCurrency(amount.rate.currency.name.lowercase()) + .setNativeAmount(amount.fiat) + ).setNonce( + Model.UUID.newBuilder().setValue(nonce.bytes.toByteString()) + ).apply { setSignature(sign(owner)) }.build() + + return runCatching { + transactionApi.declareFiatPurchase(request) + .firstOrNull() ?: throw IllegalArgumentException() + }.map { response -> + when (response.result) { + DeclareFiatOnrampPurchaseAttemptResponse.Result.OK -> Result.success(Unit) + DeclareFiatOnrampPurchaseAttemptResponse.Result.INVALID_OWNER -> { + val error = Throwable("Error: INVALID_OWNER") + ErrorUtils.handleError(error) + Result.failure(error) + } + + DeclareFiatOnrampPurchaseAttemptResponse.Result.UNSUPPORTED_CURRENCY -> { + val error = Throwable("Error: UNSUPPORTED_CURRENCY") + ErrorUtils.handleError(error) + Result.failure(error) + } + + DeclareFiatOnrampPurchaseAttemptResponse.Result.AMOUNT_EXCEEDS_MAXIMUM -> { + val error = Throwable("Error: AMOUNT_EXCEEDS_MAXIMUM") + ErrorUtils.handleError(error) + Result.failure(error) + } + + DeclareFiatOnrampPurchaseAttemptResponse.Result.UNRECOGNIZED -> { + val error = Throwable("Error: UNRECOGNIZED") + ErrorUtils.handleError(error) + Result.failure(error) + } + + else -> { + val error = Throwable("Error: Unknown Error") + ErrorUtils.handleError(error) + Result.failure(error) + } + } + } + } + + // TODO: potentially make this more generic in the event we introduce more airdrop types + // that can be requested for + suspend fun requestFirstKinAirdrop(owner: KeyPair): Result { + val request = TransactionService.AirdropRequest.newBuilder() + .setOwner(owner.publicKeyBytes.toSolanaAccount()) + .setAirdropType(TransactionService.AirdropType.GET_FIRST_KIN) + .apply { setSignature(sign(owner)) } + .build() + + return runCatching { + transactionApi.airdrop(request) + .toFlowable() + .asFlow() + .flowOn(Dispatchers.IO) + .map { + when (it.result) { + TransactionService.AirdropResponse.Result.OK -> { + KinAmount.fromProtoExchangeData(it.exchangeData) + } + + TransactionService.AirdropResponse.Result.ALREADY_CLAIMED -> { + throw AirdropException.AlreadyClaimedException() + } + + TransactionService.AirdropResponse.Result.UNAVAILABLE -> { + throw AirdropException.UnavailableException() + } + + else -> { + throw AirdropException.UnknownException() + } + } + }.first() + } + } + + suspend fun fetchIntentMetadata( + owner: KeyPair, + intentId: PublicKey + ): Result { + val request = TransactionService.GetIntentMetadataRequest.newBuilder() + .setIntentId(intentId.toIntentId()) + .setOwner(owner.publicKeyBytes.toSolanaAccount()) + .apply { setSignature(sign(owner)) } + .build() + + return runCatching { + transactionApi.getIntentMetadata(request) + .toFlowable() + .asFlow() + .flowOn(Dispatchers.IO) + .map { + if (it.result != TransactionService.GetIntentMetadataResponse.Result.OK) { + throw IllegalStateException() + } else { + IntentMetadata.newInstance(it.metadata) ?: throw IllegalStateException() + } + } + .firstOrNull() ?: throw IllegalStateException() + } + } + + @SuppressLint("CheckResult") + fun fetchLimits( + owner: KeyPair, + timestamp: Long + ): Flowable { + val request = TransactionService.GetLimitsRequest.newBuilder() + .setOwner(owner.publicKeyBytes.toSolanaAccount()) + .setConsumedSince(Timestamp.newBuilder().setSeconds(timestamp)) + .apply { setSignature(sign(owner)) } + .build() + + return transactionApi.getLimits(request) + .flatMap { + if (it.result != TransactionService.GetLimitsResponse.Result.OK) { + Single.error(IllegalStateException()) + } else { + Limits.newInstance( + sinceDate = timestamp, + fetchDate = System.currentTimeMillis(), + sendLimits = it.sendLimitsByCurrencyMap, + buyLimits = it.buyModuleLimitsByCurrencyMap, + deposits = it.depositLimit + ).let { limits -> + Single.just(limits) + } + } + } + .doOnSuccess { + setLimits(it) + setMaximumDeposit(it.maxDeposit) + trace( + tag = "Trx", + message = "Fetched limits", + type = TraceType.Process, + metadata = { + val sendLimit = it.sendLimitFor(CurrencyCode.USD) + if (sendLimit != null) { + "limitNextTx" to sendLimit + } + } + ) + } + .doOnError(ErrorUtils::handleError) + .toFlowable() + } + + fun fetchDestinationMetadata(destination: PublicKey): Single { + val request = TransactionService.CanWithdrawToAccountRequest.newBuilder() + .setAccount(destination.bytes.toSolanaAccount()) + .build() + + return transactionApi.canWithdrawToAccount(request).map { + DestinationMetadata.newInstance( + destination = destination, + isValid = it.isValidPaymentDestination, + kind = DestinationMetadata.Kind.tryValueOf(it.accountType.name) + ?: DestinationMetadata.Kind.Unknown + ) + } + .onErrorReturn { + DestinationMetadata.newInstance( + destination = destination, + isValid = false, + kind = DestinationMetadata.Kind.Unknown + ) + } + } + + fun fetchUpgradeableIntents(owner: KeyPair): Single> { + val request = TransactionService.GetPrioritizedIntentsForPrivacyUpgradeRequest.newBuilder() + .setOwner(owner.publicKeyBytes.toSolanaAccount()) + .setLimit(100) //TODO: implement paging + .apply { setSignature(sign(owner)) } + .build() + + return transactionApi.getPrioritizedIntentsForPrivacyUpgrade(request).flatMap { + when (it.result) { + TransactionService.GetPrioritizedIntentsForPrivacyUpgradeResponse.Result.OK -> { + try { + val items = it.itemsList.map { item -> UpgradeableIntent.newInstance(item) } + Single.just(items) + } catch (e: UpgradeablePrivateAction.UpgradeablePrivateActionException.DeserializationFailedException) { + Single.error(FetchUpgradeableIntentsException.DeserializationException()) + } catch (e: Exception) { + Single.error(e) + } + } + + TransactionService.GetPrioritizedIntentsForPrivacyUpgradeResponse.Result.NOT_FOUND -> { + Single.just(listOf()) + } + + else -> { + Single.error(FetchUpgradeableIntentsException.UnknownException()) + } + } + } + } + + data class DestinationMetadata( + val destination: PublicKey, + val isValid: Boolean, + val kind: Kind, + + val hasResolvedDestination: Boolean, + val resolvedDestination: PublicKey + ) { + enum class Kind { + Unknown, + TokenAccount, + OwnerAccount; + + companion object { + fun tryValueOf(value: String): Kind? { + return try { + valueOf(value) + } catch (e: Exception) { + null + } + } + } + } + + companion object { + fun newInstance( + destination: PublicKey, + isValid: Boolean, + kind: Kind + ): DestinationMetadata { + val hasResolvedDestination: Boolean + val resolvedDestination: PublicKey + + when (kind) { + Kind.Unknown, Kind.TokenAccount -> { + hasResolvedDestination = false + resolvedDestination = destination + } + + Kind.OwnerAccount -> { + hasResolvedDestination = true + resolvedDestination = + AssociatedTokenAccount.newInstance( + owner = destination, + mint = Mint.kin + ).ata.publicKey + } + } + + return DestinationMetadata( + destination = destination, + isValid = isValid, + kind = kind, + hasResolvedDestination = hasResolvedDestination, + resolvedDestination = resolvedDestination + ) + } + } + } +} + +class ErrorSubmitIntentException( + val errorSubmitIntent: ErrorSubmitIntent, + cause: Throwable? = null, + val messageString: String = "" +) : Exception(cause) { + override val message: String + get() = "${errorSubmitIntent.javaClass.simpleName} $messageString" +} + +enum class DeniedReason { + Unspecified, + TooManyFreeAccountsForPhoneNumber, + TooManyFreeAccountsForDevice, + UnsupportedCountry, + UnsupportedDevice; + + companion object { + fun fromValue(value: Int): DeniedReason { + return entries.firstOrNull { it.ordinal == value } ?: Unspecified + } + } +} + +sealed class ErrorSubmitIntent(val value: Int) { + data class Denied(val reasons: List = emptyList()) : ErrorSubmitIntent(0) + data class InvalidIntent(val reasons: List) : ErrorSubmitIntent(1) + data object SignatureError : ErrorSubmitIntent(2) + data class StaleState(val reasons: List) : ErrorSubmitIntent(3) + data object Unknown : ErrorSubmitIntent(-1) + data object DeviceTokenUnavailable : ErrorSubmitIntent(-2) + + override fun toString(): String { + return when (this) { + is Denied -> "denied(${reasons.joinToString()})" + DeviceTokenUnavailable -> "deviceTokenUnavailable" + is InvalidIntent -> "invalidIntent(${reasons.joinToString()})" + SignatureError -> "signatureError" + is StaleState -> "staleState(${reasons.joinToString()})" + Unknown -> "unknown" + } + } + + companion object { + operator fun invoke(proto: SubmitIntentResponse.Error): ErrorSubmitIntent { + val reasonStrings = proto.errorDetailsList.mapNotNull { + when (it.typeCase) { + TransactionService.ErrorDetails.TypeCase.REASON_STRING -> + it.reasonString.reason.takeIf { reason -> reason.isNotEmpty() } + + else -> null + } + } + return when (proto.code) { + SubmitIntentResponse.Error.Code.DENIED -> { + val reasons = proto.errorDetailsList.mapNotNull { + if (!it.hasDenied()) return@mapNotNull null + DeniedReason.fromValue(it.denied.codeValue) + } + + Denied(reasons) + } + + SubmitIntentResponse.Error.Code.INVALID_INTENT -> InvalidIntent(reasonStrings) + SubmitIntentResponse.Error.Code.SIGNATURE_ERROR -> SignatureError + SubmitIntentResponse.Error.Code.STALE_STATE -> StaleState(reasonStrings) + SubmitIntentResponse.Error.Code.UNRECOGNIZED -> Unknown + else -> return Unknown + } + } + } +} + +sealed class WithdrawException : Exception() { + class InvalidFractionalKinAmountException : WithdrawException() + class InsufficientFundsException : WithdrawException() +} + +sealed class FetchUpgradeableIntentsException : Exception() { + class DeserializationException : FetchUpgradeableIntentsException() + class UnknownException : FetchUpgradeableIntentsException() +} + +sealed class AirdropException : Exception() { + class AlreadyClaimedException : AirdropException() + class UnavailableException : AirdropException() + class UnknownException : AirdropException() +} diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/TransactionRepository_Swap.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/TransactionRepository_Swap.kt new file mode 100644 index 000000000..d351330c0 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/repository/TransactionRepository_Swap.kt @@ -0,0 +1,173 @@ +package com.getcode.network.repository + +import com.codeinc.gen.transaction.v2.CodeTransactionService as TransactionService +import com.codeinc.gen.transaction.v2.CodeTransactionService.SwapRequest +import com.codeinc.gen.transaction.v2.CodeTransactionService.SwapRequest.Initiate +import com.codeinc.gen.transaction.v2.CodeTransactionService.SwapResponse +import com.getcode.model.intents.SwapConfigParameters +import com.getcode.model.intents.SwapIntent +import com.getcode.model.intents.requestToSubmitSignatures +import com.getcode.services.observers.BidirectionalStreamReference +import com.getcode.solana.SolanaTransaction +import com.getcode.solana.diff +import com.getcode.solana.keys.base58 +import com.getcode.solana.organizer.Organizer +import com.getcode.utils.ErrorUtils +import io.grpc.stub.StreamObserver +import kotlinx.coroutines.suspendCancellableCoroutine +import timber.log.Timber +import kotlin.coroutines.resume + +typealias BidirectionalSwapStream = BidirectionalStreamReference + +suspend fun TransactionRepository.initiateSwap(organizer: Organizer): Result { + val intent = SwapIntent.newInstance(organizer) + + Timber.d("Swap ID: ${intent.id.base58()}") + + return submit(intent) +} + +private suspend fun TransactionRepository.submit(intent: SwapIntent): Result = suspendCancellableCoroutine { cont -> + val reference = BidirectionalSwapStream(this) + + // Intentionally creates a retain-cycle using closures to ensure that we have + // a strong reference to the stream at all times. Doing so ensures that the + // callers don't have to manage the pointer to this stream and keep it alive + reference.retain() + + reference.stream = transactionApi.swap(object : StreamObserver { + override fun onNext(value: SwapResponse?) { + when (val response = value?.responseCase) { + // 2. Upon successful submission of intent action the server will + // respond with parameters that we'll need to apply to the intent + // before crafting and signing the transactions. + SwapResponse.ResponseCase.SERVER_PARAMETERS -> { + try { + val configParameters = SwapConfigParameters.invoke(value.serverParameters) ?: throw IllegalArgumentException() + intent.parameters = configParameters + + val submitSignatures = intent.requestToSubmitSignatures() + reference.stream?.onNext(submitSignatures) + Timber.d("Sent swap request, Intent: ${intent.id.base58()}") + } catch (e: Exception) { + Timber.e(t = e, message = "Received parameters but failed to apply them. Intent: ${intent.id.base58()}") + cont.resume(Result.failure(e)) + } + } + + // 3. If submitted transaction signatures are valid and match + // the server, we'll receive a success for the submitted intent. + SwapResponse.ResponseCase.SUCCESS -> { + Timber.d("Success: ${value.success.codeValue}, Intent: ${intent.id.base58()}") + reference.stream?.onCompleted() + } + + // 3. If the submitted transaction signatures don't match, the + // intent is considered failed. Something must have gone wrong + // on the transaction creation or signing on our side. + SwapResponse.ResponseCase.ERROR -> { + + val errors = mutableListOf() + + value.error.errorDetailsList.forEach { error -> + when (error.typeCase) { + TransactionService.ErrorDetails.TypeCase.REASON_STRING -> { + errors.add("Reason: ${error.reasonString}") + } + TransactionService.ErrorDetails.TypeCase.INVALID_SIGNATURE -> { + val expected = SolanaTransaction.fromList(error.invalidSignature.expectedTransaction.value.toByteArray().toList()) + val produced = intent.transaction(intent.parameters!!) + errors.addAll( + listOf( + "Action index: ${error.invalidSignature.actionId}", + "Invalid signature: ${ + com.getcode.solana.keys.Signature( + error.invalidSignature.providedSignature.value.toByteArray() + .toList() + ).base58()}", + "Transaction bytes: ${error.invalidSignature.expectedTransaction.value}", + "Transaction expected: $expected", + "Android produced: $produced" + ) + ) + + expected?.diff(produced) + } + TransactionService.ErrorDetails.TypeCase.DENIED -> { + errors.add("Denied: ${error.denied.reason}") + } + else -> Unit + } + + Timber.e( + "Error: ${errors.joinToString("\n")}" + ) + } + + reference.stream?.onCompleted() + cont.resume( + Result.failure( + ErrorSubmitSwapIntentException( + ErrorSubmitSwapIntent.valueOf(value.error.codeValue), + ) + ) + ) + } + else -> { + reference.stream?.onCompleted() + } + } + } + + override fun onError(t: Throwable?) { + Timber.i("onError: " + t?.message.orEmpty()) + t?.let { + ErrorUtils.handleError(it) + } + cont.resume( + Result.failure( + ErrorSubmitSwapIntentException(ErrorSubmitSwapIntent.Unknown, t) + ) + ) + } + + override fun onCompleted() { + Timber.i("onCompleted") + } + }) + + val initiateSwap = SwapRequest.newBuilder() + .setInitiate(Initiate.newBuilder() + .setOwner(intent.owner.publicKeyBytes.toSolanaAccount()) + .setSwapAuthority(intent.swapCluster.authorityPublicKey.byteArray.toSolanaAccount()) + .apply { setSignature(sign(intent.owner)) } + .build() + ).build() + + reference.stream?.onNext(initiateSwap) +} + +class ErrorSubmitSwapIntentException( + val errorSubmitSwapIntent: ErrorSubmitSwapIntent, + cause: Throwable? = null, + val messageString: String = "" +) : Exception(cause) { + override val message: String + get() = "${errorSubmitSwapIntent.name} $messageString" +} + +enum class ErrorSubmitSwapIntent(val value: Int) { + Denied(0), + InvalidIntent(1), + SignatureError(2), + StaleState(3), + Unknown(-1), + DeviceTokenUnavailable(-2); + + companion object { + fun valueOf(value: Int): ErrorSubmitSwapIntent { + return entries.firstOrNull { it.value == value } ?: Unknown + } + } +} diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/network/service/CurrencyService.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/network/service/CurrencyService.kt new file mode 100644 index 000000000..513184fd0 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/network/service/CurrencyService.kt @@ -0,0 +1,53 @@ +package com.getcode.network.service + +import com.getcode.model.Rate +import com.getcode.network.api.CurrencyApi +import com.getcode.services.network.core.NetworkOracle +import com.getcode.utils.ErrorUtils +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import timber.log.Timber +import javax.inject.Inject +import kotlin.time.Duration.Companion.convert +import kotlin.time.DurationUnit +import kotlin.time.ExperimentalTime + +data class ApiRateResult( + val rates: List, + val dateMillis: Long, +) + +class CurrencyService @Inject constructor( + private val api: CurrencyApi, + private val networkOracle: NetworkOracle, +) { + @OptIn(ExperimentalTime::class) + suspend fun getRates(): Result { + Timber.d("fetching rates") + return try { + networkOracle.managedRequest(api.getRates()) + .map { response -> + val rates = response.ratesMap.mapNotNull { (key, value) -> + val currency = com.getcode.model.CurrencyCode.tryValueOf(key) ?: return@mapNotNull null + Rate(fx = value, currency = currency) + }.toMutableList() + + if (rates.none { it.currency == com.getcode.model.CurrencyCode.KIN }) { + rates.add(Rate(fx = 1.0, currency = com.getcode.model.CurrencyCode.KIN)) + } + + Result.success(ApiRateResult( + rates = rates.toList(), + dateMillis = convert( + value = response.asOf.seconds.toDouble(), + sourceUnit = DurationUnit.SECONDS, + targetUnit = DurationUnit.MILLISECONDS + ).toLong() + )) + }.first() + } catch (e: Exception) { + ErrorUtils.handleError(e) + Result.failure(e) + } + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/com/getcode/utils/SignMessage.kt b/services/flipchat/payments/src/main/kotlin/com/getcode/utils/SignMessage.kt new file mode 100644 index 000000000..1a9f9392e --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/com/getcode/utils/SignMessage.kt @@ -0,0 +1,18 @@ +package com.getcode.utils + +import com.codeinc.gen.common.v1.CodeModel +import com.getcode.ed25519.Ed25519 +import com.getcode.network.repository.toSignature +import com.google.protobuf.GeneratedMessageLite +import java.io.ByteArrayOutputStream + +fun , B : GeneratedMessageLite.Builder> GeneratedMessageLite.Builder.sign(owner: Ed25519.KeyPair): CodeModel.Signature { + // dump message up until this point into a ByteArray + val bos = ByteArrayOutputStream() + this.buildPartial().writeTo(bos) + + /** + * sign message up to this point with owner and return as [com.codeinc.gen.common.v1.Model.Signature] + */ + return Ed25519.sign(bos.toByteArray(), owner).toSignature() +} diff --git a/services/flipchat/payments/src/main/kotlin/xyz/flipchat/services/FcPaymentsConfig.kt b/services/flipchat/payments/src/main/kotlin/xyz/flipchat/services/FcPaymentsConfig.kt new file mode 100644 index 000000000..9b741c7b1 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/xyz/flipchat/services/FcPaymentsConfig.kt @@ -0,0 +1,9 @@ +package xyz.flipchat.services + +import com.getcode.services.ChannelConfig +import xyz.flipchat.services.payments.BuildConfig + +internal data class FcPaymentsConfig( + override val baseUrl: String = "payments.api.flipchat-infra.xyz", + override val userAgent: String = "Flipchat/Payments/Android/${BuildConfig.VERSION_NAME}", +): ChannelConfig diff --git a/services/flipchat/payments/src/main/kotlin/xyz/flipchat/services/PaymentController.kt b/services/flipchat/payments/src/main/kotlin/xyz/flipchat/services/PaymentController.kt new file mode 100644 index 000000000..d5320d258 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/xyz/flipchat/services/PaymentController.kt @@ -0,0 +1,249 @@ +package xyz.flipchat.services + +import androidx.compose.runtime.staticCompositionLocalOf +import com.getcode.domain.BillController +import com.getcode.manager.TopBarManager +import com.getcode.model.Currency +import com.getcode.model.ID +import com.getcode.model.KinAmount +import com.getcode.models.BillState +import com.getcode.models.ConfirmationState +import com.getcode.models.MessageTipPaymentConfirmation +import com.getcode.models.PublicPaymentConfirmation +import com.getcode.network.BalanceController +import com.getcode.network.repository.PaymentError +import com.getcode.network.repository.PaymentRepository +import com.getcode.services.model.ExtendedMetadata +import com.getcode.solana.keys.PublicKey +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.CurrencyUtils +import com.getcode.utils.Kin +import com.getcode.utils.flagResId +import com.getcode.utils.formatAmountString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import xyz.flipchat.services.payments.R +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds + +data class PaymentState( + val billState: BillState = BillState.Default, +) + +sealed interface PaymentEvent { + data class OnPaymentSuccess( + val intentId: ID, + val destination: PublicKey, + val metadata: ExtendedMetadata?, + val amount: KinAmount, + val acknowledge: (Boolean, () -> Unit) -> Unit // Caller returns true if they want to proceed as success, false as error + ) : PaymentEvent + data object OnPaymentCancelled : PaymentEvent + data class OnPaymentError(val error: Throwable): PaymentEvent +} + +val LocalPaymentController = staticCompositionLocalOf { null } + +@Singleton +open class PaymentController @Inject constructor( + private val paymentRepository: PaymentRepository, + private val resources: ResourceHelper, + private val billController: BillController, + private val balanceController: BalanceController, + private val currencyUtils: CurrencyUtils, +) { + protected val scope = CoroutineScope(Dispatchers.IO) + + val state = billController.state.map { + PaymentState(it) + }.stateIn(scope, started = SharingStarted.Eagerly, initialValue = PaymentState()) + + protected val _eventFlow: MutableSharedFlow = MutableSharedFlow() + val eventFlow: SharedFlow = _eventFlow.asSharedFlow() + + fun presentPublicPaymentConfirmation( + destination: PublicKey, + amount: KinAmount, + metadata: ExtendedMetadata, + ) { + billController.update { + it.copy( + publicPaymentConfirmation = PublicPaymentConfirmation( + state = ConfirmationState.AwaitingConfirmation, + amount = amount, + destination = destination, + metadata = metadata, + ) + ) + } + } + + fun completePublicPayment() = + scope.launch { + val confirmation = billController.state.value.publicPaymentConfirmation ?: return@launch + val destination = confirmation.destination + val amount = confirmation.amount + val metadata = confirmation.metadata + + billController.update { + it.copy( + publicPaymentConfirmation = it.publicPaymentConfirmation?.copy(state = ConfirmationState.Sending), + ) + } + + runCatching { + paymentRepository.payPublicly(amount, destination, metadata) + }.onSuccess { + _eventFlow.emit(PaymentEvent.OnPaymentSuccess( + intentId = it, + destination = destination, + metadata = metadata, + amount = amount, + acknowledge = { isSuccess, after -> + if (isSuccess) { + scope.launch { + billController.update { billState -> + val publicPaymentConfirmation = + billState.publicPaymentConfirmation ?: return@update billState + billState.copy( + publicPaymentConfirmation = publicPaymentConfirmation.copy(state = ConfirmationState.Sent), + ) + } + delay(1.33.seconds) + cancelPayment(fromUser = false) + after() + } + } else { + billController.reset() + after() + + } + } + )) + }.onFailure { + when { + it is PaymentError -> { + when (it) { + is PaymentError.InsufficientBalance -> presentInsufficientFundsError() + is PaymentError.OrganizerNotFound -> presentPaymentFailedError() + } + } + else -> presentPaymentFailedError() + } + + _eventFlow.emit(PaymentEvent.OnPaymentError(it)) + + billController.reset() + } + } + + fun cancelPayment(fromUser: Boolean = true) { + scope.launch { + billController.reset() + if (fromUser) { + _eventFlow.emit(PaymentEvent.OnPaymentCancelled) + } + } + } + + fun presentMessageTipConfirmation(metadata: ExtendedMetadata, destination: PublicKey) { + val rawBalance = balanceController.rawBalance + val balance = formatAmountString( + resources, + Currency.Kin, + rawBalance, + suffix = resources.getKinSuffix() + ) + + billController.update { + it.copy( + messageTipPaymentConfirmation = MessageTipPaymentConfirmation( + state = ConfirmationState.AwaitingConfirmation, + metadata = metadata, + destination = destination, + balance = balance, + ) + ) + } + } + + fun completeMessageTip(amount: KinAmount) = + scope.launch { + val confirmation = billController.state.value.messageTipPaymentConfirmation ?: return@launch + val destination = confirmation.destination + val metadata = confirmation.metadata + + billController.update { + it.copy( + messageTipPaymentConfirmation = it.messageTipPaymentConfirmation?.copy(state = ConfirmationState.Sending), + ) + } + + runCatching { + paymentRepository.payPublicly(amount, destination, metadata) + }.onSuccess { + _eventFlow.emit(PaymentEvent.OnPaymentSuccess( + intentId = it, + destination = destination, + metadata = metadata, + amount = amount, + acknowledge = { isSuccess, after -> + if (isSuccess) { + scope.launch { + billController.update { billState -> + val messageTipPaymentConfirmation = + billState.messageTipPaymentConfirmation ?: return@update billState + billState.copy( + messageTipPaymentConfirmation = messageTipPaymentConfirmation.copy(state = ConfirmationState.Sent), + ) + } + cancelPayment(fromUser = false) + after() + } + } else { + billController.reset() + after() + + } + } + )) + }.onFailure { + when { + it is PaymentError -> { + when (it) { + is PaymentError.InsufficientBalance -> presentInsufficientFundsError() + is PaymentError.OrganizerNotFound -> presentPaymentFailedError() + } + } + else -> presentPaymentFailedError() + } + + _eventFlow.emit(PaymentEvent.OnPaymentError(it)) + + billController.reset() + } + } + + private fun presentInsufficientFundsError() { + TopBarManager.showMessage( + resources.getString(R.string.error_title_paymentFailedDueToInsufficientFunds), + resources.getString(R.string.error_description_paymentFailedDueToInsufficientFunds), + ) + } + + private fun presentPaymentFailedError() { + TopBarManager.showMessage( + resources.getString(R.string.error_title_paymentFailed), + resources.getString(R.string.error_description_paymentFailed), + ) + } +} \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/xyz/flipchat/services/internal/annotations/PaymentsManagedChannel.kt b/services/flipchat/payments/src/main/kotlin/xyz/flipchat/services/internal/annotations/PaymentsManagedChannel.kt new file mode 100644 index 000000000..a4eabbf9f --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/xyz/flipchat/services/internal/annotations/PaymentsManagedChannel.kt @@ -0,0 +1,13 @@ +package xyz.flipchat.services.internal.annotations + +import javax.inject.Qualifier + +@Qualifier +@Target( + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER, + AnnotationTarget.VALUE_PARAMETER, + AnnotationTarget.FIELD +) +annotation class PaymentsManagedChannel \ No newline at end of file diff --git a/services/flipchat/payments/src/main/kotlin/xyz/flipchat/services/internal/inject/FcPaymentsModule.kt b/services/flipchat/payments/src/main/kotlin/xyz/flipchat/services/internal/inject/FcPaymentsModule.kt new file mode 100644 index 000000000..f9efae676 --- /dev/null +++ b/services/flipchat/payments/src/main/kotlin/xyz/flipchat/services/internal/inject/FcPaymentsModule.kt @@ -0,0 +1,48 @@ +package xyz.flipchat.services.internal.inject + +import android.content.Context +import com.getcode.services.utils.logging.LoggingClientInterceptor +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import io.grpc.ManagedChannel +import io.grpc.android.AndroidChannelBuilder +import org.kin.sdk.base.network.api.agora.OkHttpChannelBuilderForcedTls12 +import xyz.flipchat.services.FcPaymentsConfig +import xyz.flipchat.services.internal.annotations.PaymentsManagedChannel +import xyz.flipchat.services.payments.BuildConfig +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object FcPaymentsModule { + + @Singleton + @Provides + fun providesPaymentsServicesConfig(): FcPaymentsConfig { + return FcPaymentsConfig() + } + + @Singleton + @Provides + @PaymentsManagedChannel + fun provideManagedChannel( + @ApplicationContext context: Context, + config: FcPaymentsConfig, + ): ManagedChannel { + return AndroidChannelBuilder + .usingBuilder(OkHttpChannelBuilderForcedTls12.forAddress(config.baseUrl, config.port)) + .context(context) + .userAgent(config.userAgent) + .keepAliveTime(config.keepAlive.inWholeMilliseconds, TimeUnit.MILLISECONDS) + .apply { + if (BuildConfig.DEBUG) { + this.intercept(LoggingClientInterceptor()) + } + } + .build() + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/.gitignore b/services/flipchat/sdk/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/services/flipchat/sdk/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/api/build.gradle.kts b/services/flipchat/sdk/build.gradle.kts similarity index 74% rename from api/build.gradle.kts rename to services/flipchat/sdk/build.gradle.kts index 3fe8eb31c..716fbc9b7 100644 --- a/api/build.gradle.kts +++ b/services/flipchat/sdk/build.gradle.kts @@ -1,5 +1,3 @@ -import com.google.protobuf.gradle.protobuf - plugins { id(Plugins.android_library) id(Plugins.kotlin_android) @@ -8,7 +6,7 @@ plugins { } android { - namespace = "${Android.namespace}.api" + namespace = "${Android.flipchatNamespace}.services.sdk" compileSdk = Android.compileSdkVersion defaultConfig { minSdk = Android.minSdkVersion @@ -16,6 +14,10 @@ android { buildToolsVersion = Android.buildToolsVersion testInstrumentationRunner = Android.testInstrumentationRunner + consumerProguardFiles("consumer-rules.pro") + + buildConfigField("String", "VERSION_NAME", "\"${Packaging.Flipchat.versionName}\"") + buildConfigField("Boolean", "NOTIFY_ERRORS", "false") buildConfigField( "String", @@ -36,19 +38,6 @@ android { } } - buildTypes { - getByName("release") { - buildConfigField("Boolean", "NOTIFY_ERRORS", "true") - } - getByName("debug") { - buildConfigField( - "Boolean", - "NOTIFY_ERRORS", - tryReadProperty(rootProject.rootDir, "NOTIFY_ERRORS", "false") - ) - } - } - java { toolchain { languageVersion.set(JavaLanguageVersion.of(Versions.java)) @@ -58,8 +47,6 @@ android { kotlinOptions { jvmTarget = Versions.java freeCompilerArgs += listOf( - "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", - "-Xuse-experimental=kotlinx.coroutines.FlowPreview", "-opt-in=kotlin.ExperimentalUnsignedTypes", "-opt-in=kotlin.RequiresOptIn" ) @@ -71,28 +58,37 @@ android { } dependencies { - implementation(project(":common:resources")) - api(project(":service:models")) - implementation(project(":crypto:ed25519")) - implementation(project(":crypto:kin")) + implementation(project(":definitions:flipchat:models")) + api(project(":services:flipchat:core")) + api(project(":services:flipchat:chat")) + api(project(":services:flipchat:payments")) + implementation(project(":libs:requests")) + implementation(project(":ui:resources")) + + implementation(platform(Libs.compose_bom)) + implementation(Libs.compose_ui) - implementation(Libs.rxjava) implementation(Libs.kotlinx_coroutines_core) implementation(Libs.kotlinx_serialization_json) - implementation(Libs.kotlinx_datetime) implementation(Libs.inject) + implementation(Libs.grpc_android) implementation(Libs.grpc_okhttp) implementation(Libs.grpc_kotlin) implementation(Libs.androidx_lifecycle_runtime) implementation(Libs.androidx_room_runtime) implementation(Libs.androidx_room_ktx) - implementation(Libs.androidx_room_rxjava3) implementation(Libs.androidx_room_paging) + implementation(Libs.androidx_room_rxjava3) implementation(Libs.okhttp) implementation(Libs.mixpanel) implementation(platform(Libs.firebase_bom)) + implementation(Libs.firebase_crashlytics) + implementation(Libs.firebase_installations) + implementation(Libs.firebase_perf) + implementation(Libs.firebase_messaging) + implementation(Libs.play_integrity) implementation(Libs.androidx_paging_runtime) @@ -100,22 +96,17 @@ dependencies { kapt(Libs.androidx_room_compiler) implementation(Libs.sqlcipher) - api(Libs.sodium_bindings) - implementation(Libs.fingerprint_pro) implementation(Libs.lib_phone_number_google) - implementation(platform(Libs.firebase_bom)) - implementation(Libs.firebase_crashlytics) - implementation(Libs.firebase_installations) - implementation(Libs.firebase_perf) - implementation(Libs.firebase_messaging) - androidTestImplementation(Libs.androidx_junit) androidTestImplementation(Libs.junit) androidTestImplementation(Libs.androidx_test_runner) + implementation(Libs.hilt) + kapt(Libs.hilt_android_compiler) + kapt(Libs.hilt_compiler) implementation(Libs.timber) implementation(Libs.bugsnag) diff --git a/services/flipchat/sdk/consumer-rules.pro b/services/flipchat/sdk/consumer-rules.pro new file mode 100644 index 000000000..5f642a47c --- /dev/null +++ b/services/flipchat/sdk/consumer-rules.pro @@ -0,0 +1,6 @@ +# Needed to keep generic signatures +-keepattributes Signature + +-keepclasseswithmembernames class * { + native ; +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/1.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/1.json new file mode 100644 index 000000000..2240fdcc9 --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/1.json @@ -0,0 +1,260 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "21df295ddc43f1ac6661169bf5cca785", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `title` TEXT, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `dateMillis` INTEGER NOT NULL, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "message_contents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageIdBase58` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`messageIdBase58`, `content`))", + "fields": [ + { + "fieldPath": "messageIdBase58", + "columnName": "messageIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageIdBase58", + "content" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, '21df295ddc43f1ac6661169bf5cca785')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/10.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/10.json new file mode 100644 index 000000000..12d4e2b0e --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/10.json @@ -0,0 +1,338 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "7217ed18e0e307ed362d1e7aaf05229f", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `ownerIdBase58` TEXT, `title` TEXT NOT NULL, `roomNumber` INTEGER NOT NULL DEFAULT 0, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, `coverChargeQuarks` INTEGER, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerIdBase58", + "columnName": "ownerIdBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roomNumber", + "columnName": "roomNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverChargeQuarks", + "columnName": "coverChargeQuarks", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, `isHost` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isHost", + "columnName": "isHost", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `senderIdBase58` TEXT NOT NULL DEFAULT '', `dateMillis` INTEGER NOT NULL, `deleted` INTEGER, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderIdBase58", + "columnName": "senderIdBase58", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "message_contents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageIdBase58` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`messageIdBase58`, `content`))", + "fields": [ + { + "fieldPath": "messageIdBase58", + "columnName": "messageIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageIdBase58", + "content" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, '7217ed18e0e307ed362d1e7aaf05229f')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/11.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/11.json new file mode 100644 index 000000000..c137208fe --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/11.json @@ -0,0 +1,345 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "781e4bc06ff540e47ea520e5e2f39f35", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `ownerIdBase58` TEXT, `title` TEXT NOT NULL, `roomNumber` INTEGER NOT NULL DEFAULT 0, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, `coverChargeQuarks` INTEGER, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerIdBase58", + "columnName": "ownerIdBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roomNumber", + "columnName": "roomNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverChargeQuarks", + "columnName": "coverChargeQuarks", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, `isHost` INTEGER NOT NULL DEFAULT false, `isMuted` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isHost", + "columnName": "isHost", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `senderIdBase58` TEXT NOT NULL DEFAULT '', `dateMillis` INTEGER NOT NULL, `deleted` INTEGER, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderIdBase58", + "columnName": "senderIdBase58", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "message_contents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageIdBase58` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`messageIdBase58`, `content`))", + "fields": [ + { + "fieldPath": "messageIdBase58", + "columnName": "messageIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageIdBase58", + "content" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, '781e4bc06ff540e47ea520e5e2f39f35')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/12.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/12.json new file mode 100644 index 000000000..6662559d7 --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/12.json @@ -0,0 +1,402 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "5fb9b50af4f9ba1005b386c7b1cbdb7b", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `ownerIdBase58` TEXT, `title` TEXT NOT NULL, `roomNumber` INTEGER NOT NULL DEFAULT 0, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, `coverChargeQuarks` INTEGER, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerIdBase58", + "columnName": "ownerIdBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roomNumber", + "columnName": "roomNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverChargeQuarks", + "columnName": "coverChargeQuarks", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, `isHost` INTEGER NOT NULL DEFAULT false, `isMuted` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isHost", + "columnName": "isHost", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [ + { + "name": "index_members_memberIdBase58", + "unique": false, + "columnNames": [ + "memberIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_memberIdBase58` ON `${TABLE_NAME}` (`memberIdBase58`)" + }, + { + "name": "index_members_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `senderIdBase58` TEXT NOT NULL DEFAULT '', `dateMillis` INTEGER NOT NULL, `deleted` INTEGER, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderIdBase58", + "columnName": "senderIdBase58", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [ + { + "name": "index_messages_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + }, + { + "name": "index_messages_senderIdBase58", + "unique": false, + "columnNames": [ + "senderIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_senderIdBase58` ON `${TABLE_NAME}` (`senderIdBase58`)" + }, + { + "name": "index_messages_dateMillis", + "unique": false, + "columnNames": [ + "dateMillis" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_dateMillis` ON `${TABLE_NAME}` (`dateMillis`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "message_contents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageIdBase58` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`messageIdBase58`, `content`))", + "fields": [ + { + "fieldPath": "messageIdBase58", + "columnName": "messageIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageIdBase58", + "content" + ] + }, + "indices": [ + { + "name": "index_message_contents_messageIdBase58", + "unique": false, + "columnNames": [ + "messageIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_message_contents_messageIdBase58` ON `${TABLE_NAME}` (`messageIdBase58`)" + } + ], + "foreignKeys": [] + } + ], + "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, '5fb9b50af4f9ba1005b386c7b1cbdb7b')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/13.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/13.json new file mode 100644 index 000000000..0d6b0118b --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/13.json @@ -0,0 +1,409 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "aedd745db33530cad54445629f3dbc43", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `ownerIdBase58` TEXT, `title` TEXT NOT NULL, `roomNumber` INTEGER NOT NULL DEFAULT 0, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `canMute` INTEGER NOT NULL DEFAULT true, `unreadCount` INTEGER NOT NULL, `coverChargeQuarks` INTEGER, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerIdBase58", + "columnName": "ownerIdBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roomNumber", + "columnName": "roomNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canMute", + "columnName": "canMute", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverChargeQuarks", + "columnName": "coverChargeQuarks", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, `isHost` INTEGER NOT NULL DEFAULT false, `isMuted` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isHost", + "columnName": "isHost", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [ + { + "name": "index_members_memberIdBase58", + "unique": false, + "columnNames": [ + "memberIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_memberIdBase58` ON `${TABLE_NAME}` (`memberIdBase58`)" + }, + { + "name": "index_members_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `senderIdBase58` TEXT NOT NULL DEFAULT '', `dateMillis` INTEGER NOT NULL, `deleted` INTEGER, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderIdBase58", + "columnName": "senderIdBase58", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [ + { + "name": "index_messages_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + }, + { + "name": "index_messages_senderIdBase58", + "unique": false, + "columnNames": [ + "senderIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_senderIdBase58` ON `${TABLE_NAME}` (`senderIdBase58`)" + }, + { + "name": "index_messages_dateMillis", + "unique": false, + "columnNames": [ + "dateMillis" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_dateMillis` ON `${TABLE_NAME}` (`dateMillis`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "message_contents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageIdBase58` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`messageIdBase58`, `content`))", + "fields": [ + { + "fieldPath": "messageIdBase58", + "columnName": "messageIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageIdBase58", + "content" + ] + }, + "indices": [ + { + "name": "index_message_contents_messageIdBase58", + "unique": false, + "columnNames": [ + "messageIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_message_contents_messageIdBase58` ON `${TABLE_NAME}` (`messageIdBase58`)" + } + ], + "foreignKeys": [] + } + ], + "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, 'aedd745db33530cad54445629f3dbc43')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/14.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/14.json new file mode 100644 index 000000000..365127591 --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/14.json @@ -0,0 +1,416 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "51fe51d1440bd508116bb2339ac5de55", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `ownerIdBase58` TEXT, `title` TEXT NOT NULL, `roomNumber` INTEGER NOT NULL DEFAULT 0, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `canMute` INTEGER NOT NULL DEFAULT true, `unreadCount` INTEGER NOT NULL, `coverChargeQuarks` INTEGER, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerIdBase58", + "columnName": "ownerIdBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roomNumber", + "columnName": "roomNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canMute", + "columnName": "canMute", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverChargeQuarks", + "columnName": "coverChargeQuarks", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, `isHost` INTEGER NOT NULL DEFAULT false, `isMuted` INTEGER NOT NULL DEFAULT false, `isFullMember` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isHost", + "columnName": "isHost", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isFullMember", + "columnName": "isFullMember", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [ + { + "name": "index_members_memberIdBase58", + "unique": false, + "columnNames": [ + "memberIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_memberIdBase58` ON `${TABLE_NAME}` (`memberIdBase58`)" + }, + { + "name": "index_members_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `senderIdBase58` TEXT NOT NULL DEFAULT '', `dateMillis` INTEGER NOT NULL, `deleted` INTEGER, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderIdBase58", + "columnName": "senderIdBase58", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [ + { + "name": "index_messages_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + }, + { + "name": "index_messages_senderIdBase58", + "unique": false, + "columnNames": [ + "senderIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_senderIdBase58` ON `${TABLE_NAME}` (`senderIdBase58`)" + }, + { + "name": "index_messages_dateMillis", + "unique": false, + "columnNames": [ + "dateMillis" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_dateMillis` ON `${TABLE_NAME}` (`dateMillis`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "message_contents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageIdBase58` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`messageIdBase58`, `content`))", + "fields": [ + { + "fieldPath": "messageIdBase58", + "columnName": "messageIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageIdBase58", + "content" + ] + }, + "indices": [ + { + "name": "index_message_contents_messageIdBase58", + "unique": false, + "columnNames": [ + "messageIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_message_contents_messageIdBase58` ON `${TABLE_NAME}` (`messageIdBase58`)" + } + ], + "foreignKeys": [] + } + ], + "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, '51fe51d1440bd508116bb2339ac5de55')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/15.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/15.json new file mode 100644 index 000000000..ac97ca1ec --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/15.json @@ -0,0 +1,416 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "51fe51d1440bd508116bb2339ac5de55", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `ownerIdBase58` TEXT, `title` TEXT NOT NULL, `roomNumber` INTEGER NOT NULL DEFAULT 0, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `canMute` INTEGER NOT NULL DEFAULT true, `unreadCount` INTEGER NOT NULL, `coverChargeQuarks` INTEGER, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerIdBase58", + "columnName": "ownerIdBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roomNumber", + "columnName": "roomNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canMute", + "columnName": "canMute", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverChargeQuarks", + "columnName": "coverChargeQuarks", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, `isHost` INTEGER NOT NULL DEFAULT false, `isMuted` INTEGER NOT NULL DEFAULT false, `isFullMember` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isHost", + "columnName": "isHost", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isFullMember", + "columnName": "isFullMember", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [ + { + "name": "index_members_memberIdBase58", + "unique": false, + "columnNames": [ + "memberIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_memberIdBase58` ON `${TABLE_NAME}` (`memberIdBase58`)" + }, + { + "name": "index_members_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `senderIdBase58` TEXT NOT NULL DEFAULT '', `dateMillis` INTEGER NOT NULL, `deleted` INTEGER, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderIdBase58", + "columnName": "senderIdBase58", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [ + { + "name": "index_messages_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + }, + { + "name": "index_messages_senderIdBase58", + "unique": false, + "columnNames": [ + "senderIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_senderIdBase58` ON `${TABLE_NAME}` (`senderIdBase58`)" + }, + { + "name": "index_messages_dateMillis", + "unique": false, + "columnNames": [ + "dateMillis" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_dateMillis` ON `${TABLE_NAME}` (`dateMillis`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "message_contents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageIdBase58` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`messageIdBase58`, `content`))", + "fields": [ + { + "fieldPath": "messageIdBase58", + "columnName": "messageIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageIdBase58", + "content" + ] + }, + "indices": [ + { + "name": "index_message_contents_messageIdBase58", + "unique": false, + "columnNames": [ + "messageIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_message_contents_messageIdBase58` ON `${TABLE_NAME}` (`messageIdBase58`)" + } + ], + "foreignKeys": [] + } + ], + "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, '51fe51d1440bd508116bb2339ac5de55')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/16.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/16.json new file mode 100644 index 000000000..e55bf1b89 --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/16.json @@ -0,0 +1,423 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "cb1501f407ce2507e171c285e685bc0c", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `ownerIdBase58` TEXT, `title` TEXT NOT NULL, `roomNumber` INTEGER NOT NULL DEFAULT 0, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `canMute` INTEGER NOT NULL DEFAULT true, `unreadCount` INTEGER NOT NULL, `coverChargeQuarks` INTEGER, `hasMoreUnread` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerIdBase58", + "columnName": "ownerIdBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roomNumber", + "columnName": "roomNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canMute", + "columnName": "canMute", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverChargeQuarks", + "columnName": "coverChargeQuarks", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasMoreUnread", + "columnName": "hasMoreUnread", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, `isHost` INTEGER NOT NULL DEFAULT false, `isMuted` INTEGER NOT NULL DEFAULT false, `isFullMember` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isHost", + "columnName": "isHost", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isFullMember", + "columnName": "isFullMember", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [ + { + "name": "index_members_memberIdBase58", + "unique": false, + "columnNames": [ + "memberIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_memberIdBase58` ON `${TABLE_NAME}` (`memberIdBase58`)" + }, + { + "name": "index_members_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `senderIdBase58` TEXT NOT NULL DEFAULT '', `dateMillis` INTEGER NOT NULL, `deleted` INTEGER, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderIdBase58", + "columnName": "senderIdBase58", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [ + { + "name": "index_messages_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + }, + { + "name": "index_messages_senderIdBase58", + "unique": false, + "columnNames": [ + "senderIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_senderIdBase58` ON `${TABLE_NAME}` (`senderIdBase58`)" + }, + { + "name": "index_messages_dateMillis", + "unique": false, + "columnNames": [ + "dateMillis" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_dateMillis` ON `${TABLE_NAME}` (`dateMillis`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "message_contents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageIdBase58` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`messageIdBase58`, `content`))", + "fields": [ + { + "fieldPath": "messageIdBase58", + "columnName": "messageIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageIdBase58", + "content" + ] + }, + "indices": [ + { + "name": "index_message_contents_messageIdBase58", + "unique": false, + "columnNames": [ + "messageIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_message_contents_messageIdBase58` ON `${TABLE_NAME}` (`messageIdBase58`)" + } + ], + "foreignKeys": [] + } + ], + "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, 'cb1501f407ce2507e171c285e685bc0c')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/17.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/17.json new file mode 100644 index 000000000..f5e873ef1 --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/17.json @@ -0,0 +1,400 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "identityHash": "7472edb154421ae341376a6cb19c5af1", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `ownerIdBase58` TEXT, `title` TEXT NOT NULL, `roomNumber` INTEGER NOT NULL DEFAULT 0, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `canMute` INTEGER NOT NULL DEFAULT true, `unreadCount` INTEGER NOT NULL, `coverChargeQuarks` INTEGER, `hasMoreUnread` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerIdBase58", + "columnName": "ownerIdBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roomNumber", + "columnName": "roomNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canMute", + "columnName": "canMute", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverChargeQuarks", + "columnName": "coverChargeQuarks", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasMoreUnread", + "columnName": "hasMoreUnread", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, `isHost` INTEGER NOT NULL DEFAULT false, `isMuted` INTEGER NOT NULL DEFAULT false, `isFullMember` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isHost", + "columnName": "isHost", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isFullMember", + "columnName": "isFullMember", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [ + { + "name": "index_members_memberIdBase58", + "unique": false, + "columnNames": [ + "memberIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_memberIdBase58` ON `${TABLE_NAME}` (`memberIdBase58`)" + }, + { + "name": "index_members_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `senderIdBase58` TEXT NOT NULL DEFAULT '', `dateMillis` INTEGER NOT NULL, `deleted` INTEGER, `type` INTEGER NOT NULL DEFAULT 1, `content` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderIdBase58", + "columnName": "senderIdBase58", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [ + { + "name": "index_messages_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + }, + { + "name": "index_messages_senderIdBase58", + "unique": false, + "columnNames": [ + "senderIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_senderIdBase58` ON `${TABLE_NAME}` (`senderIdBase58`)" + }, + { + "name": "index_messages_dateMillis", + "unique": false, + "columnNames": [ + "dateMillis" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_dateMillis` ON `${TABLE_NAME}` (`dateMillis`)" + } + ], + "foreignKeys": [] + } + ], + "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, '7472edb154421ae341376a6cb19c5af1')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/18.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/18.json new file mode 100644 index 000000000..e597f92c7 --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/18.json @@ -0,0 +1,407 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "ac813724bab87a6eb46acca31b65a3b5", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `ownerIdBase58` TEXT, `title` TEXT NOT NULL, `roomNumber` INTEGER NOT NULL DEFAULT 0, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `canMute` INTEGER NOT NULL DEFAULT true, `unreadCount` INTEGER NOT NULL, `coverChargeQuarks` INTEGER, `hasMoreUnread` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerIdBase58", + "columnName": "ownerIdBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roomNumber", + "columnName": "roomNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canMute", + "columnName": "canMute", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverChargeQuarks", + "columnName": "coverChargeQuarks", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasMoreUnread", + "columnName": "hasMoreUnread", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, `isHost` INTEGER NOT NULL DEFAULT false, `isMuted` INTEGER NOT NULL DEFAULT false, `isFullMember` INTEGER NOT NULL DEFAULT false, `isBlocked` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isHost", + "columnName": "isHost", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isFullMember", + "columnName": "isFullMember", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isBlocked", + "columnName": "isBlocked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [ + { + "name": "index_members_memberIdBase58", + "unique": false, + "columnNames": [ + "memberIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_memberIdBase58` ON `${TABLE_NAME}` (`memberIdBase58`)" + }, + { + "name": "index_members_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `senderIdBase58` TEXT NOT NULL DEFAULT '', `dateMillis` INTEGER NOT NULL, `deleted` INTEGER, `type` INTEGER NOT NULL DEFAULT 1, `content` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderIdBase58", + "columnName": "senderIdBase58", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [ + { + "name": "index_messages_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + }, + { + "name": "index_messages_senderIdBase58", + "unique": false, + "columnNames": [ + "senderIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_senderIdBase58` ON `${TABLE_NAME}` (`senderIdBase58`)" + }, + { + "name": "index_messages_dateMillis", + "unique": false, + "columnNames": [ + "dateMillis" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_dateMillis` ON `${TABLE_NAME}` (`dateMillis`)" + } + ], + "foreignKeys": [] + } + ], + "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, 'ac813724bab87a6eb46acca31b65a3b5')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/19.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/19.json new file mode 100644 index 000000000..a1850d893 --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/19.json @@ -0,0 +1,413 @@ +{ + "formatVersion": 1, + "database": { + "version": 19, + "identityHash": "9c9f066316b87831894e6f65cbdfc60f", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `ownerIdBase58` TEXT, `title` TEXT NOT NULL, `roomNumber` INTEGER NOT NULL DEFAULT 0, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `canMute` INTEGER NOT NULL DEFAULT true, `unreadCount` INTEGER NOT NULL, `coverChargeQuarks` INTEGER, `hasMoreUnread` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerIdBase58", + "columnName": "ownerIdBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roomNumber", + "columnName": "roomNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canMute", + "columnName": "canMute", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverChargeQuarks", + "columnName": "coverChargeQuarks", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasMoreUnread", + "columnName": "hasMoreUnread", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, `isHost` INTEGER NOT NULL DEFAULT false, `isMuted` INTEGER NOT NULL DEFAULT false, `isFullMember` INTEGER NOT NULL DEFAULT false, `isBlocked` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isHost", + "columnName": "isHost", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isFullMember", + "columnName": "isFullMember", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isBlocked", + "columnName": "isBlocked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [ + { + "name": "index_members_memberIdBase58", + "unique": false, + "columnNames": [ + "memberIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_memberIdBase58` ON `${TABLE_NAME}` (`memberIdBase58`)" + }, + { + "name": "index_members_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `senderIdBase58` TEXT NOT NULL DEFAULT '', `dateMillis` INTEGER NOT NULL, `deleted` INTEGER, `deletedByBase58` TEXT, `type` INTEGER NOT NULL DEFAULT 1, `content` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderIdBase58", + "columnName": "senderIdBase58", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deletedByBase58", + "columnName": "deletedByBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [ + { + "name": "index_messages_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + }, + { + "name": "index_messages_senderIdBase58", + "unique": false, + "columnNames": [ + "senderIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_senderIdBase58` ON `${TABLE_NAME}` (`senderIdBase58`)" + }, + { + "name": "index_messages_dateMillis", + "unique": false, + "columnNames": [ + "dateMillis" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_dateMillis` ON `${TABLE_NAME}` (`dateMillis`)" + } + ], + "foreignKeys": [] + } + ], + "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, '9c9f066316b87831894e6f65cbdfc60f')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/2.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/2.json new file mode 100644 index 000000000..01118ddb4 --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/2.json @@ -0,0 +1,299 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "73d09888385f047520f90e3ae9fc75f1", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `title` TEXT NOT NULL, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `dateMillis` INTEGER NOT NULL, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "message_contents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageIdBase58` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`messageIdBase58`, `content`))", + "fields": [ + { + "fieldPath": "messageIdBase58", + "columnName": "messageIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageIdBase58", + "content" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, '73d09888385f047520f90e3ae9fc75f1')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/20.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/20.json new file mode 100644 index 000000000..e302f5412 --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/20.json @@ -0,0 +1,420 @@ +{ + "formatVersion": 1, + "database": { + "version": 20, + "identityHash": "0c22cc344f2b02a0048048bfe634dbe7", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `ownerIdBase58` TEXT, `title` TEXT NOT NULL, `roomNumber` INTEGER NOT NULL DEFAULT 0, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `canMute` INTEGER NOT NULL DEFAULT true, `unreadCount` INTEGER NOT NULL, `coverChargeQuarks` INTEGER, `hasMoreUnread` INTEGER NOT NULL DEFAULT false, `isOpen` INTEGER NOT NULL DEFAULT true, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerIdBase58", + "columnName": "ownerIdBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roomNumber", + "columnName": "roomNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canMute", + "columnName": "canMute", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverChargeQuarks", + "columnName": "coverChargeQuarks", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasMoreUnread", + "columnName": "hasMoreUnread", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isOpen", + "columnName": "isOpen", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, `isHost` INTEGER NOT NULL DEFAULT false, `isMuted` INTEGER NOT NULL DEFAULT false, `isFullMember` INTEGER NOT NULL DEFAULT false, `isBlocked` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isHost", + "columnName": "isHost", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isFullMember", + "columnName": "isFullMember", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isBlocked", + "columnName": "isBlocked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [ + { + "name": "index_members_memberIdBase58", + "unique": false, + "columnNames": [ + "memberIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_memberIdBase58` ON `${TABLE_NAME}` (`memberIdBase58`)" + }, + { + "name": "index_members_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `senderIdBase58` TEXT NOT NULL DEFAULT '', `dateMillis` INTEGER NOT NULL, `deleted` INTEGER, `deletedByBase58` TEXT, `type` INTEGER NOT NULL DEFAULT 1, `content` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderIdBase58", + "columnName": "senderIdBase58", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deletedByBase58", + "columnName": "deletedByBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [ + { + "name": "index_messages_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + }, + { + "name": "index_messages_senderIdBase58", + "unique": false, + "columnNames": [ + "senderIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_senderIdBase58` ON `${TABLE_NAME}` (`senderIdBase58`)" + }, + { + "name": "index_messages_dateMillis", + "unique": false, + "columnNames": [ + "dateMillis" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_dateMillis` ON `${TABLE_NAME}` (`dateMillis`)" + } + ], + "foreignKeys": [] + } + ], + "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, '0c22cc344f2b02a0048048bfe634dbe7')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/21.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/21.json new file mode 100644 index 000000000..e6c821ed5 --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/21.json @@ -0,0 +1,481 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "c34cc1c2cab1a9bb6e2f0e7d0beb9413", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `ownerIdBase58` TEXT, `title` TEXT NOT NULL, `roomNumber` INTEGER NOT NULL DEFAULT 0, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `canMute` INTEGER NOT NULL DEFAULT true, `unreadCount` INTEGER NOT NULL, `coverChargeQuarks` INTEGER, `hasMoreUnread` INTEGER NOT NULL DEFAULT false, `isOpen` INTEGER NOT NULL DEFAULT true, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerIdBase58", + "columnName": "ownerIdBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roomNumber", + "columnName": "roomNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canMute", + "columnName": "canMute", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverChargeQuarks", + "columnName": "coverChargeQuarks", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasMoreUnread", + "columnName": "hasMoreUnread", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isOpen", + "columnName": "isOpen", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, `isHost` INTEGER NOT NULL DEFAULT false, `isMuted` INTEGER NOT NULL DEFAULT false, `isFullMember` INTEGER NOT NULL DEFAULT false, `isBlocked` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isHost", + "columnName": "isHost", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isFullMember", + "columnName": "isFullMember", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isBlocked", + "columnName": "isBlocked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [ + { + "name": "index_members_memberIdBase58", + "unique": false, + "columnNames": [ + "memberIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_memberIdBase58` ON `${TABLE_NAME}` (`memberIdBase58`)" + }, + { + "name": "index_members_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `senderIdBase58` TEXT NOT NULL DEFAULT '', `dateMillis` INTEGER NOT NULL, `deleted` INTEGER, `deletedByBase58` TEXT, `inReplyToBase58` TEXT, `tipCount` INTEGER NOT NULL DEFAULT 0, `type` INTEGER NOT NULL DEFAULT 1, `content` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderIdBase58", + "columnName": "senderIdBase58", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deletedByBase58", + "columnName": "deletedByBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToBase58", + "columnName": "inReplyToBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tipCount", + "columnName": "tipCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [ + { + "name": "index_messages_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + }, + { + "name": "index_messages_senderIdBase58", + "unique": false, + "columnNames": [ + "senderIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_senderIdBase58` ON `${TABLE_NAME}` (`senderIdBase58`)" + }, + { + "name": "index_messages_dateMillis", + "unique": false, + "columnNames": [ + "dateMillis" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_dateMillis` ON `${TABLE_NAME}` (`dateMillis`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "tips", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `messageIdBase58` TEXT NOT NULL, `amount` INTEGER NOT NULL, `tipperIdBase58` TEXT NOT NULL, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdBase58", + "columnName": "messageIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tipperIdBase58", + "columnName": "tipperIdBase58", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [ + { + "name": "index_tips_messageIdBase58", + "unique": false, + "columnNames": [ + "messageIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tips_messageIdBase58` ON `${TABLE_NAME}` (`messageIdBase58`)" + } + ], + "foreignKeys": [] + } + ], + "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, 'c34cc1c2cab1a9bb6e2f0e7d0beb9413')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/22.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/22.json new file mode 100644 index 000000000..9e4361481 --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/22.json @@ -0,0 +1,494 @@ +{ + "formatVersion": 1, + "database": { + "version": 22, + "identityHash": "ddf50bec418df53870ee520e05dcb3cc", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `ownerIdBase58` TEXT, `title` TEXT NOT NULL, `roomNumber` INTEGER NOT NULL DEFAULT 0, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `canMute` INTEGER NOT NULL DEFAULT true, `unreadCount` INTEGER NOT NULL, `coverChargeQuarks` INTEGER, `hasMoreUnread` INTEGER NOT NULL DEFAULT false, `isOpen` INTEGER NOT NULL DEFAULT true, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerIdBase58", + "columnName": "ownerIdBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roomNumber", + "columnName": "roomNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canMute", + "columnName": "canMute", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messagingFee", + "columnName": "coverChargeQuarks", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasMoreUnread", + "columnName": "hasMoreUnread", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isOpen", + "columnName": "isOpen", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, `isHost` INTEGER NOT NULL DEFAULT false, `isMuted` INTEGER NOT NULL DEFAULT false, `isFullMember` INTEGER NOT NULL DEFAULT false, `isBlocked` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isHost", + "columnName": "isHost", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isFullMember", + "columnName": "isFullMember", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isBlocked", + "columnName": "isBlocked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [ + { + "name": "index_members_memberIdBase58", + "unique": false, + "columnNames": [ + "memberIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_memberIdBase58` ON `${TABLE_NAME}` (`memberIdBase58`)" + }, + { + "name": "index_members_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_members_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `senderIdBase58` TEXT NOT NULL DEFAULT '', `dateMillis` INTEGER NOT NULL, `deleted` INTEGER, `deletedByBase58` TEXT, `inReplyToBase58` TEXT, `sentOffStage` INTEGER NOT NULL DEFAULT false, `isApproved` INTEGER, `tipCount` INTEGER NOT NULL DEFAULT 0, `type` INTEGER NOT NULL DEFAULT 1, `content` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderIdBase58", + "columnName": "senderIdBase58", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deletedByBase58", + "columnName": "deletedByBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToBase58", + "columnName": "inReplyToBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sentOffStage", + "columnName": "sentOffStage", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isApproved", + "columnName": "isApproved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tipCount", + "columnName": "tipCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [ + { + "name": "index_messages_conversationIdBase58", + "unique": false, + "columnNames": [ + "conversationIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationIdBase58` ON `${TABLE_NAME}` (`conversationIdBase58`)" + }, + { + "name": "index_messages_senderIdBase58", + "unique": false, + "columnNames": [ + "senderIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_senderIdBase58` ON `${TABLE_NAME}` (`senderIdBase58`)" + }, + { + "name": "index_messages_dateMillis", + "unique": false, + "columnNames": [ + "dateMillis" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_dateMillis` ON `${TABLE_NAME}` (`dateMillis`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "tips", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `messageIdBase58` TEXT NOT NULL, `amount` INTEGER NOT NULL, `tipperIdBase58` TEXT NOT NULL, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdBase58", + "columnName": "messageIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tipperIdBase58", + "columnName": "tipperIdBase58", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [ + { + "name": "index_tips_messageIdBase58", + "unique": false, + "columnNames": [ + "messageIdBase58" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tips_messageIdBase58` ON `${TABLE_NAME}` (`messageIdBase58`)" + } + ], + "foreignKeys": [] + } + ], + "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, 'ddf50bec418df53870ee520e05dcb3cc')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/3.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/3.json new file mode 100644 index 000000000..cba5037cc --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/3.json @@ -0,0 +1,306 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "bb5c31bd3d5381054721acae94b59342", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `title` TEXT NOT NULL, `roomNumber` INTEGER NOT NULL DEFAULT 0, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roomNumber", + "columnName": "roomNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `dateMillis` INTEGER NOT NULL, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "message_contents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageIdBase58` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`messageIdBase58`, `content`))", + "fields": [ + { + "fieldPath": "messageIdBase58", + "columnName": "messageIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageIdBase58", + "content" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, 'bb5c31bd3d5381054721acae94b59342')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/4.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/4.json new file mode 100644 index 000000000..9d285201e --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/4.json @@ -0,0 +1,312 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "cbb2ea84e3bb3240d7b9a075d7454677", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `title` TEXT NOT NULL, `roomNumber` INTEGER NOT NULL DEFAULT 0, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roomNumber", + "columnName": "roomNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `dateMillis` INTEGER NOT NULL, `deleted` INTEGER, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "message_contents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageIdBase58` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`messageIdBase58`, `content`))", + "fields": [ + { + "fieldPath": "messageIdBase58", + "columnName": "messageIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageIdBase58", + "content" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, 'cbb2ea84e3bb3240d7b9a075d7454677')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/5.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/5.json new file mode 100644 index 000000000..34450bcb8 --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/5.json @@ -0,0 +1,319 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "9f3802b9cb1f5539419c93ddc19965f0", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `title` TEXT NOT NULL, `roomNumber` INTEGER NOT NULL DEFAULT 0, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roomNumber", + "columnName": "roomNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `senderIdBase58` TEXT NOT NULL DEFAULT '', `dateMillis` INTEGER NOT NULL, `deleted` INTEGER, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderIdBase58", + "columnName": "senderIdBase58", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "message_contents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageIdBase58` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`messageIdBase58`, `content`))", + "fields": [ + { + "fieldPath": "messageIdBase58", + "columnName": "messageIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageIdBase58", + "content" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, '9f3802b9cb1f5539419c93ddc19965f0')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/6.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/6.json new file mode 100644 index 000000000..29bcad1fe --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/6.json @@ -0,0 +1,326 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "b674ac7360aca7a357892a8d70f74722", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `title` TEXT NOT NULL, `roomNumber` INTEGER NOT NULL DEFAULT 0, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roomNumber", + "columnName": "roomNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, `isHost` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isHost", + "columnName": "isHost", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `senderIdBase58` TEXT NOT NULL DEFAULT '', `dateMillis` INTEGER NOT NULL, `deleted` INTEGER, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderIdBase58", + "columnName": "senderIdBase58", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "message_contents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageIdBase58` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`messageIdBase58`, `content`))", + "fields": [ + { + "fieldPath": "messageIdBase58", + "columnName": "messageIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageIdBase58", + "content" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, 'b674ac7360aca7a357892a8d70f74722')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/7.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/7.json new file mode 100644 index 000000000..cfddcd4f4 --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/7.json @@ -0,0 +1,326 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "b674ac7360aca7a357892a8d70f74722", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `title` TEXT NOT NULL, `roomNumber` INTEGER NOT NULL DEFAULT 0, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roomNumber", + "columnName": "roomNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, `isHost` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isHost", + "columnName": "isHost", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `senderIdBase58` TEXT NOT NULL DEFAULT '', `dateMillis` INTEGER NOT NULL, `deleted` INTEGER, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderIdBase58", + "columnName": "senderIdBase58", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "message_contents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageIdBase58` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`messageIdBase58`, `content`))", + "fields": [ + { + "fieldPath": "messageIdBase58", + "columnName": "messageIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageIdBase58", + "content" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, 'b674ac7360aca7a357892a8d70f74722')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/8.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/8.json new file mode 100644 index 000000000..a3a1538bb --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/8.json @@ -0,0 +1,332 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "b519189cb8dcbb32702186e5d00a61d5", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `ownerIdBase58` TEXT, `title` TEXT NOT NULL, `roomNumber` INTEGER NOT NULL DEFAULT 0, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerIdBase58", + "columnName": "ownerIdBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roomNumber", + "columnName": "roomNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, `isHost` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isHost", + "columnName": "isHost", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `senderIdBase58` TEXT NOT NULL DEFAULT '', `dateMillis` INTEGER NOT NULL, `deleted` INTEGER, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderIdBase58", + "columnName": "senderIdBase58", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "message_contents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageIdBase58` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`messageIdBase58`, `content`))", + "fields": [ + { + "fieldPath": "messageIdBase58", + "columnName": "messageIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageIdBase58", + "content" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, 'b519189cb8dcbb32702186e5d00a61d5')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/9.json b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/9.json new file mode 100644 index 000000000..a564ab292 --- /dev/null +++ b/services/flipchat/sdk/schemas/xyz.flipchat.internal.db.FcAppDatabase/9.json @@ -0,0 +1,338 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "7217ed18e0e307ed362d1e7aaf05229f", + "entities": [ + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `ownerIdBase58` TEXT, `title` TEXT NOT NULL, `roomNumber` INTEGER NOT NULL DEFAULT 0, `imageUri` TEXT, `lastActivity` INTEGER, `isMuted` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, `coverChargeQuarks` INTEGER, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerIdBase58", + "columnName": "ownerIdBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roomNumber", + "columnName": "roomNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverChargeQuarks", + "columnName": "coverChargeQuarks", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberIdBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `memberName` TEXT, `imageUri` TEXT, `isHost` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`memberIdBase58`, `conversationIdBase58`))", + "fields": [ + { + "fieldPath": "memberIdBase58", + "columnName": "memberIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberName", + "columnName": "memberName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageUri", + "columnName": "imageUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isHost", + "columnName": "isHost", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberIdBase58", + "conversationIdBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversation_pointers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationIdBase58` TEXT NOT NULL, `messageIdString` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`conversationIdBase58`, `status`))", + "fields": [ + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdString", + "columnName": "messageIdString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationIdBase58", + "status" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `senderIdBase58` TEXT NOT NULL DEFAULT '', `dateMillis` INTEGER NOT NULL, `deleted` INTEGER, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderIdBase58", + "columnName": "senderIdBase58", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "message_contents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageIdBase58` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`messageIdBase58`, `content`))", + "fields": [ + { + "fieldPath": "messageIdBase58", + "columnName": "messageIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageIdBase58", + "content" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, '7217ed18e0e307ed362d1e7aaf05229f')" + ] + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/FlipchatServices.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/FlipchatServices.kt new file mode 100644 index 000000000..0c3d92991 --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/FlipchatServices.kt @@ -0,0 +1,18 @@ +package xyz.flipchat + +import android.content.Context +import com.getcode.services.db.Database +import xyz.flipchat.internal.db.FcAppDatabase +import javax.inject.Inject + +class FlipchatServices @Inject constructor() { + + companion object { + fun openDatabase(context: Context, entropy: String) { + if (!FcAppDatabase.isOpen()) { + FcAppDatabase.init(context, entropy) + Database.register(FcAppDatabase.requireInstance()) + } + } + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/chat/RoomController.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/chat/RoomController.kt new file mode 100644 index 000000000..d206fc489 --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/chat/RoomController.kt @@ -0,0 +1,367 @@ +package xyz.flipchat.chat + +import androidx.core.app.NotificationManagerCompat +import androidx.paging.ExperimentalPagingApi +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.room.withTransaction +import com.getcode.model.ID +import com.getcode.model.KinAmount +import com.getcode.model.chat.MessageStatus +import com.getcode.model.uuid +import com.getcode.services.model.chat.OutgoingMessageContent +import com.getcode.utils.base58 +import com.getcode.utils.timestamp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import xyz.flipchat.chat.paging.MessagingPagingSource +import xyz.flipchat.chat.paging.MessagingRemoteMediator +import xyz.flipchat.internal.db.FcAppDatabase +import xyz.flipchat.notifications.getRoomNotifications +import xyz.flipchat.services.data.ChatIdentifier +import xyz.flipchat.services.domain.mapper.ConversationMessageMapper +import xyz.flipchat.services.domain.model.chat.ConversationMember +import xyz.flipchat.services.domain.model.chat.ConversationMessageWithMemberAndContent +import xyz.flipchat.services.domain.model.chat.ConversationWithMembersAndLastPointers +import xyz.flipchat.services.domain.model.chat.InflatedConversationMessage +import xyz.flipchat.services.internal.data.mapper.ConversationMemberMapper +import xyz.flipchat.services.internal.network.repository.chat.ChatRepository +import xyz.flipchat.services.internal.network.repository.messaging.MessagingRepository +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject + +class RoomController @Inject constructor( + private val chatRepository: ChatRepository, + private val messagingRepository: MessagingRepository, + private val conversationMemberMapper: ConversationMemberMapper, + private val conversationMessageMapper: ConversationMessageMapper, + private val notificationManager: NotificationManagerCompat, + private val userManager: UserManager, +) { + private val db: FcAppDatabase + get() = FcAppDatabase.requireInstance() + + fun observeConversation(id: ID): Flow { + return db.conversationDao().observeConversation(id) + } + + fun observeMembersIn(id: ID): Flow> { + return db.conversationMembersDao().observeMembersIn(id) + } + + suspend fun getConversation(identifier: ID): ConversationWithMembersAndLastPointers? { + return db.conversationDao().findConversation(identifier) + } + + suspend fun getMessage(id: ID): ConversationMessageWithMemberAndContent? { + return db.conversationMessageDao().getMessageWithContentById(id, userManager.userId) + } + + suspend fun getUnreadCount(identifier: ID): Int { + return db.conversationDao().getUnreadCount(identifier) ?: 0 + } + + suspend fun getChatMembers(identifier: ID) { + chatRepository.getChatMembers(ChatIdentifier.Id(identifier)) + .onSuccess { + val dbMembers = it.map { m -> conversationMemberMapper.map(identifier to m) } + val memberIds = dbMembers.map { member -> member.id.base58 } + db.withTransaction { + withContext(Dispatchers.IO) { + db.conversationMembersDao().purgeMembersNotIn(identifier, memberIds) + db.conversationMembersDao().upsertMembers(*dbMembers.toTypedArray()) + } + } + } + } + + fun openMessageStream(scope: CoroutineScope, identifier: ID) { + scope.launch { + val name = db.conversationDao().findConversation(identifier)?.conversation?.title + val notifications = notificationManager.getRoomNotifications(identifier, name.orEmpty()) + notifications.onEach { notificationId -> + notificationManager.cancel(notificationId) + } + } + + runCatching { + messagingRepository.openMessageStream( + coroutineScope = scope, + chatId = identifier, + onMessagesUpdated = { + scope.launch { + db.conversationMessageDao().upsertMessages(it, userManager.userId) + } + } + ) + } + } + + fun closeMessageStream() { + runCatching { messagingRepository.closeMessageStream() } + } + + suspend fun resetUnreadCount(conversationId: ID) { + db.conversationDao().resetUnreadCount(conversationId) + } + + suspend fun advancePointer( + conversationId: ID, + messageId: ID, + status: MessageStatus + ) { + when (status) { + MessageStatus.Sent -> { + messagingRepository.advancePointer(conversationId, messageId, status) + .onSuccess { + withContext(Dispatchers.IO) { + db.conversationPointersDao() + .insert(conversationId, messageId.uuid!!, status) + } + } + } + + MessageStatus.Delivered -> { + messagingRepository.advancePointer(conversationId, messageId, status) + .onSuccess { + withContext(Dispatchers.IO) { + db.conversationPointersDao() + .insert(conversationId, messageId.uuid!!, status) + } + } + } + + MessageStatus.Read -> { + messagingRepository.advancePointer(conversationId, messageId, status) + .onSuccess { + withContext(Dispatchers.IO) { + db.withTransaction { + db.conversationPointersDao() + .insert(conversationId, messageId.uuid!!, status) + val newest = + db.conversationMessageDao().getNewestMessage(conversationId) + if ((messageId.uuid?.timestamp + ?: -1) >= (newest?.id?.uuid?.timestamp ?: 0L) + ) { + db.conversationDao().resetUnreadCount(conversationId) + } + } + } + } + } + + MessageStatus.Unknown -> Unit + } + } + + suspend fun sendMessage(conversationId: ID, message: String, paymentIntentId: ID? = null): Result { + val content = OutgoingMessageContent.Text(message, paymentIntentId) + return messagingRepository.sendMessage(conversationId, content) + .map { it.id } + } + + suspend fun sendReply(conversationId: ID, originalMessageId: ID, message: String): Result { + val content = OutgoingMessageContent.Reply(originalMessageId, message) + return messagingRepository.sendMessage(conversationId, content) + .map { it.id } + } + + suspend fun sendReaction(conversationId: ID, messageId: ID, emoji: String): Result { + val content = OutgoingMessageContent.Reaction(messageId, emoji) + return messagingRepository.sendMessage(conversationId, content) + .map { it.id } + } + + suspend fun sendTip( + conversationId: ID, + messageId: ID, + amount: KinAmount, + paymentIntentId: ID + ): Result { + val content = OutgoingMessageContent.Tip(messageId, amount, paymentIntentId) + return messagingRepository.sendMessage(conversationId, content) + .map { it.id } + } + + private val pagingConfig = + PagingConfig(pageSize = 25, initialLoadSize = 25, prefetchDistance = 10, enablePlaceholders = true) + + @OptIn(ExperimentalPagingApi::class) + fun messages(conversationId: ID): Pager = + Pager( + config = pagingConfig, + remoteMediator = MessagingRemoteMediator( + chatId = conversationId, + repository = messagingRepository, + conversationMessageMapper = conversationMessageMapper, + userId = { userManager.userId } + ) + ) { + MessagingPagingSource(conversationId, { userManager.userId }, db) + } + + val typingChats = chatRepository.typingChats + + fun observeTyping(conversationId: ID) = chatRepository.observeTyping(conversationId) + + suspend fun onUserStartedTypingIn(conversationId: ID) { + messagingRepository.onStartedTyping(conversationId) + } + + suspend fun onUserStoppedTypingIn(conversationId: ID) { + messagingRepository.onStoppedTyping(conversationId) + } + + suspend fun leaveRoom(conversationId: ID): Result { + return chatRepository.leaveChat(conversationId) + .onSuccess { + db.withTransaction { + withContext(Dispatchers.IO) { + db.conversationDao().deleteConversationById(conversationId) + db.conversationPointersDao().deletePointerForConversation(conversationId) + db.conversationMessageDao().removeForConversation(conversationId) + } + } + } + } + + suspend fun setDisplayName(conversationId: ID, displayName: String): Result { + return chatRepository.setDisplayName(conversationId, displayName) + .onSuccess { + val conversation = db.conversationDao() + .findConversation(conversationId)?.conversation?.copy(title = displayName) + if (conversation != null) { + db.conversationDao().setDisplayName(conversationId, displayName) + } + } + } + + suspend fun deleteMessage( + conversationId: ID, + messageId: ID, + ): Result { + return messagingRepository.deleteMessage(conversationId, messageId) + .onSuccess { + withContext(Dispatchers.IO) { + db.conversationMessageDao().markDeleted(messageId, userManager.userId!!) + } + } + } + + suspend fun removeUser( + conversationId: ID, + userId: ID + ): Result { + return chatRepository.removeUser(conversationId, userId) + .onSuccess { + withContext(Dispatchers.IO) { + db.conversationMembersDao().removeMemberFromConversation(userId, conversationId) + } + } + } + + suspend fun reportUserForMessage( + userId: ID, + messageId: ID, + ): Result { + return chatRepository.reportUserForMessage(userId, messageId) + } + + suspend fun muteUser( + chatId: ID, + userId: ID, + ): Result { + return chatRepository.muteUser(chatId, userId) + } + + suspend fun blockUser( + userId: ID, + ): Result { + withContext(Dispatchers.IO) { + db.conversationMembersDao().blockMember(userId) + } + + return Result.success(Unit) + } + + suspend fun unblockUser( + userId: ID, + ): Result { + withContext(Dispatchers.IO) { + db.conversationMembersDao().unblockMember(userId) + } + + return Result.success(Unit) + } + + @Deprecated("Replaced by setMessagingFee") + suspend fun setCoverCharge( + conversationId: ID, + amount: KinAmount + ): Result { + return chatRepository.setCoverCharge(conversationId, amount) + .onSuccess { + withContext(Dispatchers.IO) { + db.conversationDao().updateMessagingFee(conversationId, amount.kin) + } + } + } + + suspend fun promoteUser( + conversationId: ID, + userId: ID, + ): Result { + return chatRepository.promoteUser(conversationId, userId) + .onSuccess { + withContext(Dispatchers.IO) { + db.conversationMembersDao().promoteMember(conversationId, userId) + } + } + } + + suspend fun demoteUser( + conversationId: ID, + userId: ID, + ): Result { + return chatRepository.demoteUser(conversationId, userId) + .onSuccess { + withContext(Dispatchers.IO) { + db.conversationMembersDao().demoteMember(conversationId, userId) + } + } + } + + suspend fun setMessagingFee( + conversationId: ID, + amount: KinAmount + ): Result { + return chatRepository.setMessagingFee(conversationId, amount) + .onSuccess { + withContext(Dispatchers.IO) { + db.conversationDao().updateMessagingFee(conversationId, amount.kin) + } + } + } + + suspend fun enableChat(identifier: ID): Result { + return chatRepository.enableChat(identifier) + .onSuccess { + withContext(Dispatchers.IO) { + db.conversationDao().enableChatInRoom(identifier) + } + } + } + + suspend fun disableChat(identifier: ID): Result { + return chatRepository.disableChat(identifier) + .onSuccess { + withContext(Dispatchers.IO) { + db.conversationDao().disableChatInRoom(identifier) + } + } + } +} + diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/chat/paging/MessagingPagingSource.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/chat/paging/MessagingPagingSource.kt new file mode 100644 index 000000000..8e51defa8 --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/chat/paging/MessagingPagingSource.kt @@ -0,0 +1,58 @@ +package xyz.flipchat.chat.paging + +import android.annotation.SuppressLint +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.room.paging.util.ThreadSafeInvalidationObserver +import com.getcode.model.ID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import xyz.flipchat.internal.db.FcAppDatabase +import xyz.flipchat.services.domain.model.chat.InflatedConversationMessage + +internal class MessagingPagingSource( + private val chatId: ID, + private val userId: () -> ID?, + private val db: FcAppDatabase +) : PagingSource() { + + @SuppressLint("RestrictedApi") + private val observer = + ThreadSafeInvalidationObserver(arrayOf("conversations", "messages", "members")) { + invalidate() + } + + override fun getRefreshKey(state: PagingState): Int? { + val anchorPos = state.anchorPosition ?: return null + val anchorItem = state.closestItemToPosition(anchorPos) ?: return null + // The anchor item *knows* which page it was loaded from: + return anchorItem.pageIndex + } + + @SuppressLint("RestrictedApi") + override suspend fun load(params: LoadParams): LoadResult { + observer.registerIfNecessary(db) + val currentPage = params.key ?: 0 + val pageSize = params.loadSize + val offset = currentPage * pageSize + + return withContext(Dispatchers.Default) { + try { + val messages = + db.conversationMessageDao() + .getPagedMessagesWithDetails(chatId, pageSize, offset, userId()) + + val prevKey = if (currentPage > 0 && messages.isNotEmpty()) currentPage - 1 else null + val nextKey = if (messages.size < pageSize) null else currentPage + 1 + + LoadResult.Page( + data = messages.map { if (prevKey == null) it.copy(pageIndex = currentPage) else it }, + prevKey = prevKey, + nextKey = nextKey, + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } + } +} diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/chat/paging/MessagingRemoteMediator.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/chat/paging/MessagingRemoteMediator.kt new file mode 100644 index 000000000..d721de4e5 --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/chat/paging/MessagingRemoteMediator.kt @@ -0,0 +1,82 @@ +package xyz.flipchat.chat.paging + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import com.getcode.model.ID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import xyz.flipchat.internal.db.FcAppDatabase +import xyz.flipchat.services.domain.mapper.ConversationMessageMapper +import xyz.flipchat.services.domain.model.chat.InflatedConversationMessage +import xyz.flipchat.services.domain.model.query.QueryOptions +import xyz.flipchat.services.internal.network.repository.messaging.MessagingRepository + +@OptIn(ExperimentalPagingApi::class) +internal class MessagingRemoteMediator( + private val chatId: ID, + private val repository: MessagingRepository, + private val conversationMessageMapper: ConversationMessageMapper, + private val userId: () -> ID? +) : RemoteMediator() { + + private val db = FcAppDatabase.requireInstance() + + override suspend fun initialize(): InitializeAction { + return InitializeAction.SKIP_INITIAL_REFRESH + } + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + return try { + val loadKey = when (loadType) { + LoadType.REFRESH -> { + null + } + + LoadType.PREPEND -> { + return MediatorResult.Success(true) // Don't load newer messages + } + + LoadType.APPEND -> { + // Get the last item from our data + state.lastItemOrNull()?.message?.id + } + } + + val limit = state.config.pageSize + + val query = QueryOptions( + limit = limit, + token = loadKey, + descending = true + ) + + val response = withContext(Dispatchers.IO) { repository.getMessages(chatId, query) } + val messages = response.getOrNull().orEmpty() + + val conversationMessages = + messages.map { conversationMessageMapper.map(chatId to it) } + + if (conversationMessages.isEmpty()) { + return MediatorResult.Success(true) + } + + withContext(Dispatchers.IO) { + if (loadType == LoadType.REFRESH) { + db.conversationMessageDao().clearMessagesForChat(chatId) + } + + db.conversationMessageDao() + .upsertMessages(messages = conversationMessages, selfID = userId()) + } + + MediatorResult.Success(endOfPaginationReached = messages.size < limit) + } catch (e: Exception) { + MediatorResult.Error(e) + } + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/controllers/AuthController.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/controllers/AuthController.kt new file mode 100644 index 000000000..2646f23df --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/controllers/AuthController.kt @@ -0,0 +1,21 @@ +package xyz.flipchat.controllers + +import com.getcode.model.ID +import xyz.flipchat.services.internal.network.repository.accounts.AccountRepository +import javax.inject.Inject + +class AuthController @Inject constructor( + private val repository: AccountRepository, +) { + suspend fun createAccount(): Result { + return repository.createAccount() + } + + suspend fun register(displayName: String): Result { + return repository.register(displayName) + } + + suspend fun login(): Result { + return repository.login() + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/controllers/ChatsController.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/controllers/ChatsController.kt new file mode 100644 index 000000000..5b048414b --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/controllers/ChatsController.kt @@ -0,0 +1,487 @@ +package xyz.flipchat.controllers + +import android.annotation.SuppressLint +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import androidx.room.paging.util.ThreadSafeInvalidationObserver +import androidx.room.withTransaction +import com.getcode.model.ID +import com.getcode.model.chat.MessageContent +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.TraceType +import com.getcode.utils.base58 +import com.getcode.utils.trace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import xyz.flipchat.internal.db.FcAppDatabase +import xyz.flipchat.services.data.ChatIdentifier +import xyz.flipchat.services.data.Room +import xyz.flipchat.services.data.RoomWithMembers +import xyz.flipchat.services.data.StartChatRequestType +import xyz.flipchat.services.domain.mapper.ConversationMessageMapper +import xyz.flipchat.services.domain.mapper.RoomConversationMapper +import xyz.flipchat.services.domain.model.chat.ConversationMessage +import xyz.flipchat.services.domain.model.chat.ConversationWithMembersAndLastMessage +import xyz.flipchat.services.domain.model.chat.db.ConversationMemberUpdate +import xyz.flipchat.services.domain.model.chat.db.ConversationUpdate +import xyz.flipchat.services.domain.model.query.QueryOptions +import xyz.flipchat.services.extensions.titleOrFallback +import xyz.flipchat.services.internal.data.mapper.ConversationMemberMapper +import xyz.flipchat.services.internal.data.mapper.nullIfEmpty +import xyz.flipchat.services.internal.network.repository.chat.ChatRepository +import xyz.flipchat.services.internal.network.repository.messaging.MessagingRepository +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ChatsController @Inject constructor( + private val conversationMapper: RoomConversationMapper, + private val conversationMemberMapper: ConversationMemberMapper, + private val conversationMessageMapper: ConversationMessageMapper, + private val chatRepository: ChatRepository, + private val messagingRepository: MessagingRepository, + private val userManager: UserManager, + private val resources: ResourceHelper, +) { + private val db: FcAppDatabase + get() = FcAppDatabase.requireInstance() + + + private val pagingConfig = PagingConfig(pageSize = 20) + + @OptIn(ExperimentalPagingApi::class) + val chats: Pager by lazy { + Pager( + config = pagingConfig, + remoteMediator = ChatsRemoteMediator(chatRepository, conversationMapper) + ) { + ChatsPagingSource(db) + } + } + + suspend fun updateRooms() = coroutineScope { + chatRepository.getChats() + .onSuccess { rooms -> + // remove rooms no longer apart of + db.conversationDao().purgeConversationsNotIn(rooms.map { it.id }) + rooms.map { room -> + async { updateRoom(room.id) } + }.forEach { it.await() } + } + } + + suspend fun updateRoom(roomId: ID) { + chatRepository.getChat(ChatIdentifier.Id(roomId)) + .onSuccess { (room, members) -> + db.withTransaction { + withContext(Dispatchers.IO) { + db.conversationDao().upsertConversations(conversationMapper.map(room)) + members.map { conversationMemberMapper.map(room.id to it) }.onEach { + db.conversationMembersDao().upsertMembers(it) + } + } + } + + syncMessagesFromLast(conversationId = roomId) + } + } + + fun openEventStream(coroutineScope: CoroutineScope) { + runCatching { + chatRepository.openEventStream(coroutineScope) { event -> + coroutineScope.launch { + withContext(Dispatchers.IO) { + + db.withTransaction { + event.metadata.onEach { update -> + when (update) { + is ConversationUpdate.CoverCharge -> { + db.conversationDao().updateMessagingFee(update.roomId, update.amount) + } + is ConversationUpdate.DisplayName -> { + val conversation = db.conversationDao().findConversationRaw(update.roomId) + if (conversation != null) { + db.conversationDao() + .setDisplayName(update.roomId, update.name) + } + } + is ConversationUpdate.LastActivity -> { + val conversation = db.conversationDao().findConversationRaw(update.roomId) + if (conversation != null) { + db.conversationDao().upsertConversations( + conversation.copy(lastActivity = update.timestamp) + ) + } + } + is ConversationUpdate.Refresh -> db.conversationDao().upsertConversations(update.conversation) + is ConversationUpdate.UnreadCount -> { + val conversation = db.conversationDao().findConversationRaw(update.roomId) + if (conversation != null) { + db.conversationDao().upsertConversations( + conversation.copy(unreadCount = update.numUnread, hasMoreUnread = update.hasMoreUnread) + ) + } + } + + is ConversationUpdate.OpenStatus -> { + val conversation = db.conversationDao().findConversationRaw(update.roomId) + if (conversation != null) { + db.conversationDao().upsertConversations( + conversation.copy(isOpen = update.nowOpen) + ) + } + } + } + } + } + + db.withTransaction { + event.members.onEach { update -> + when (update) { + is ConversationMemberUpdate.FullRefresh -> { + db.conversationMembersDao() + .upsertMembers(*update.members.toTypedArray()) + } + + is ConversationMemberUpdate.IndividualRefresh -> { + db.conversationMembersDao().upsertMembers(update.member) + } + + is ConversationMemberUpdate.Joined -> { + db.conversationMembersDao().upsertMembers(update.member) + } + + is ConversationMemberUpdate.Left -> { + db.conversationMembersDao().removeMemberFromConversation( + memberId = update.memberId, + conversationId = update.roomId + ) + } + + is ConversationMemberUpdate.Muted -> { + db.conversationMembersDao().muteMember( + conversationId = update.roomId, + memberId = update.memberId + ) + } + + is ConversationMemberUpdate.Removed -> { + db.conversationMembersDao().removeMemberFromConversation( + memberId = update.memberId, + conversationId = update.roomId + ) + } + + is ConversationMemberUpdate.Demoted -> { + db.conversationMembersDao().demoteMember( + memberId = update.memberId, + conversationId = update.roomId + ) + } + is ConversationMemberUpdate.Promoted -> { + db.conversationMembersDao().promoteMember( + memberId = update.memberId, + conversationId = update.roomId + ) + } + } + } + } + } + + event.message?.let { newMessage -> + syncMessagesFromLast(newMessage.conversationId, newMessage) + } + } + } + } + } + + private suspend fun syncMessagesFromLast( + conversationId: ID, + newMessage: ConversationMessage? = null + ) { + var token: ID? + if (newMessage != null) { + // sync between last in DB and this message + val newestInDb = + db.conversationMessageDao().getNewestMessage(conversationId) + if (newestInDb?.id == newMessage.id) { + withContext(Dispatchers.IO) { + db.conversationMessageDao().upsertMessages(listOf(newMessage), userManager.userId) + } + return + } + + token = newestInDb?.id + } else { + val newestInDb = + db.conversationMessageDao().getNewestMessage(conversationId) + token = newestInDb?.id + } + + while (true) { + val query = QueryOptions(token = token, descending = false, limit = 1_000) + messagingRepository.getMessages(conversationId, query) + .onSuccess { syncedMessages -> + trace( + "synced ${syncedMessages.count()} missing messages for ${conversationId.base58}", + type = TraceType.Silent + ) + val messagesWithContent = syncedMessages.map { + conversationMessageMapper.map(conversationId to it) + } + + val deletions = messagesWithContent.mapNotNull { + MessageContent.fromData( + it.type, it.content, userManager.isSelf(it.senderId), + ) as? MessageContent.DeletedMessage + } + + withContext(Dispatchers.IO) { + db.conversationMessageDao().upsertMessages( + messagesWithContent, + userManager.userId + ) + + deletions.onEach { + db.conversationMessageDao().markDeleted(it.originalMessageId, it.messageDeleter) + } + } + + val nextToken = + db.conversationMessageDao().getNewestMessage(conversationId)?.id + if (nextToken == token || messagesWithContent.isEmpty()) { + return + } + + token = nextToken + } + .onFailure { + if (newMessage != null) { + withContext(Dispatchers.IO) { + db.conversationMessageDao().upsertMessages(listOf(newMessage), userManager.userId) + } + } + return + } + } + } + + fun closeEventStream() { + runCatching { + chatRepository.closeEventStream() + } + } + + suspend fun lookupRoom(roomNumber: Long): Result { + return chatRepository.getChat(identifier = ChatIdentifier.RoomNumber(roomNumber)) + } + + + suspend fun createDirectMessage(recipient: ID): Result { + return chatRepository.startChat(StartChatRequestType.TwoWay(recipient)) + .onSuccess { result -> + val members = + result.members.map { conversationMemberMapper.map(result.room.id to it) } + db.withTransaction { + withContext(Dispatchers.IO) { + db.conversationDao() + .upsertConversations(conversationMapper.map(result.room)) + db.conversationMembersDao().upsertMembers(*members.toTypedArray()) + } + } + } + } + + suspend fun createGroup( + title: String? = null, + participants: List = emptyList(), + paymentId: ID, + ): Result { + return chatRepository.startChat(StartChatRequestType.Group(title, participants, paymentId)) + .onSuccess { result -> + val members = + result.members.map { conversationMemberMapper.map(result.room.id to it) } + db.withTransaction { + withContext(Dispatchers.IO) { + db.conversationDao() + .upsertConversations(conversationMapper.map(result.room)) + db.conversationMembersDao().upsertMembers(*members.toTypedArray()) + } + } + } + } + + suspend fun joinRoomAsSpectator(roomId: ID): Result { + return chatRepository.joinChat(ChatIdentifier.Id(roomId)) + .onSuccess { result -> + val members = + result.members.map { conversationMemberMapper.map(result.room.id to it) } + db.withTransaction { + withContext(Dispatchers.IO) { + db.conversationDao() + .upsertConversations(conversationMapper.map(result.room)) + db.conversationMembersDao().upsertMembers(*members.toTypedArray()) + } + } + } + } + + suspend fun joinRoomAsFullMember(roomId: ID, paymentId: ID?): Result { + return chatRepository.joinChat(ChatIdentifier.Id(roomId), paymentId) + .onSuccess { result -> + val members = + result.members.map { conversationMemberMapper.map(result.room.id to it) } + db.withTransaction { + withContext(Dispatchers.IO) { + db.conversationDao() + .upsertConversations(conversationMapper.map(result.room)) + db.conversationMembersDao().upsertMembers(*members.toTypedArray()) + } + } + } + } + + suspend fun muteRoom(roomId: ID): Result { + return chatRepository.mute(roomId) + .onSuccess { db.conversationDao().muteChat(roomId) } + } + + suspend fun unmuteRoom(roomId: ID): Result { + return chatRepository.unmute(roomId) + .onSuccess { db.conversationDao().unmuteChat(roomId) } + } +} + +private class ChatsPagingSource( + private val db: FcAppDatabase +) : PagingSource() { + + @SuppressLint("RestrictedApi") + private val observer = + ThreadSafeInvalidationObserver(arrayOf("conversations", "messages", "members")) { + invalidate() + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1) + } + } + + fun List.middleOrNull(): T? = + if (this.isEmpty()) null else this[this.size / 2] + + @SuppressLint("RestrictedApi") + override suspend fun load(params: LoadParams): LoadResult { + observer.registerIfNecessary(db) + val currentPage = params.key ?: 0 + val pageSize = params.loadSize + val offset = currentPage * pageSize + + return withContext(Dispatchers.IO) { + try { + val conversations = db.conversationDao().getPagedConversations(pageSize, offset) + val prevKey = null + val nextKey = if (conversations.size < pageSize) null else currentPage + 1 + + + LoadResult.Page(conversations, prevKey, nextKey) + } catch (e: Exception) { + LoadResult.Error(e) + } + } + } +} + +@OptIn(ExperimentalPagingApi::class) +private class ChatsRemoteMediator( + private val repository: ChatRepository, + private val conversationMapper: RoomConversationMapper, +) : RemoteMediator() { + + private val db = FcAppDatabase.requireInstance() + + override suspend fun initialize(): InitializeAction { + return InitializeAction.SKIP_INITIAL_REFRESH + } + + private var lastResult = listOf() + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + return try { + val loadKey = when (loadType) { + LoadType.REFRESH -> { + null + } + + LoadType.PREPEND -> { + return MediatorResult.Success(true) // Don't load newer messages + } + + LoadType.APPEND -> { + // Get the last item from our data + val lastItem = state.lastItemOrNull() + ?: return MediatorResult.Success( + // If we don't have any items, only signal end of pagination + // if we've had a refresh + endOfPaginationReached = state.pages.isNotEmpty() + ) + + lastItem.conversation.id + } + } + + val limit = state.config.pageSize + + val query = QueryOptions( + limit = limit, + token = loadKey, + descending = true + ) + + val response = repository.getChats(query) + val rooms = response.getOrNull().orEmpty() + + if (rooms.isEmpty() || lastResult.any { it.id == rooms.firstOrNull()?.id.orEmpty() }) { + lastResult = emptyList() + return MediatorResult.Success(true) + } + + lastResult = rooms + + // Map the rooms to your Room entities + val conversations = rooms.map { conversationMapper.map(it) } + + // Update the database with the new data (upsert) + withContext(Dispatchers.IO) { + if (loadType == LoadType.REFRESH) { + // Clear all conversations before loading the fresh data + db.conversationDao().clearConversations() + } + + // Insert or update the conversations + db.conversationDao().upsertConversations(*conversations.toTypedArray()) + } + + MediatorResult.Success(endOfPaginationReached = rooms.size < limit) + } catch (e: Exception) { + MediatorResult.Error(e) + } + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/controllers/CodeController.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/controllers/CodeController.kt new file mode 100644 index 000000000..c42898d20 --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/controllers/CodeController.kt @@ -0,0 +1,37 @@ +package xyz.flipchat.controllers + +import android.annotation.SuppressLint +import com.getcode.model.KinAmount +import com.getcode.network.BalanceController +import com.getcode.network.client.Client +import com.getcode.network.client.receiveFromPrimaryIfWithinLimits +import com.getcode.network.repository.TransactionRepository +import io.reactivex.rxjava3.core.Completable +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject + +class CodeController @Inject constructor( + private val userManager: UserManager, + private val balanceController: BalanceController, + private val transactionRepository: TransactionRepository, + private val client: Client, +) { + @SuppressLint("CheckResult") + suspend fun requestAirdrop(): Result { + val owner = userManager.keyPair ?: return Result.failure(Throwable("No owner")) + return transactionRepository.requestFirstKinAirdrop(owner) + .onSuccess { + balanceController.fetchBalance() + + val organizer = userManager.organizer + val receiveWithinLimits = organizer?.let { + client.receiveFromPrimaryIfWithinLimits(it) + } ?: Completable.complete() + receiveWithinLimits.subscribe({}, {}) + } + } + + suspend fun fetchBalance(): Result { + return balanceController.fetchBalance() + } +} diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/controllers/ProfileController.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/controllers/ProfileController.kt new file mode 100644 index 000000000..3afbc931f --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/controllers/ProfileController.kt @@ -0,0 +1,38 @@ +package xyz.flipchat.controllers + +import com.getcode.model.ID +import com.getcode.solana.keys.PublicKey +import xyz.flipchat.services.data.PaymentTarget +import xyz.flipchat.services.user.UserFlags +import xyz.flipchat.services.domain.model.profile.UserProfile +import xyz.flipchat.services.internal.network.repository.accounts.AccountRepository +import xyz.flipchat.services.internal.network.repository.profile.ProfileRepository +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject + +class ProfileController @Inject constructor( + private val userManager: UserManager, + private val repository: ProfileRepository, + private val accountRepository: AccountRepository, +) { + + suspend fun getProfile(userId: ID): Result { + return repository.getProfile(userId) + } + + suspend fun setDisplayName(name: String): Result { + return repository.setDisplayName(name) + } + + + suspend fun getPaymentDestinationForUser(userId: ID): Result { + return accountRepository.getPaymentDestination(PaymentTarget.User(userId)) + } + + suspend fun getUserFlags(): Result { + return accountRepository.getUserFlags() + .onSuccess { + userManager.set(userFlags = it) + } + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/controllers/PurchaseController.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/controllers/PurchaseController.kt new file mode 100644 index 000000000..bcd09d8e6 --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/controllers/PurchaseController.kt @@ -0,0 +1,12 @@ +package xyz.flipchat.controllers + +import xyz.flipchat.services.internal.network.repository.iap.InAppPurchaseRepository +import javax.inject.Inject + +class PurchaseController @Inject constructor( + private val repository: InAppPurchaseRepository +) { + suspend fun onPurchaseCompleted(receipt: String): Result { + return repository.onPurchaseCompleted(receipt) + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/controllers/PushController.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/controllers/PushController.kt new file mode 100644 index 000000000..9cae877bc --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/controllers/PushController.kt @@ -0,0 +1,34 @@ +package xyz.flipchat.controllers + +import com.getcode.services.utils.installationId +import com.getcode.utils.ErrorUtils +import com.google.firebase.Firebase +import com.google.firebase.installations.installations +import xyz.flipchat.services.internal.network.repository.push.PushRepository +import xyz.flipchat.services.user.UserManager +import javax.inject.Inject + +class PushController @Inject constructor( + private val userManager: UserManager, + private val repository: PushRepository, +) { + suspend fun addToken(token: String): Result { + val owner = userManager.keyPair ?: return Result.failure(Throwable("No owner")) + val installationId = Firebase.installations.installationId() + return repository.addToken(owner, token, installationId) + .onFailure { ErrorUtils.handleError(it) } + } + + suspend fun deleteToken(token: String): Result { + val owner = userManager.keyPair ?: return Result.failure(Throwable("No owner")) + return repository.deleteToken(owner, token) + .onFailure { ErrorUtils.handleError(it) } + } + + suspend fun deleteTokens(): Result { + val owner = userManager.keyPair ?: return Result.failure(Throwable("No owner")) + val installationId = Firebase.installations.installationId() + return repository.deleteTokens(owner, installationId) + .onFailure { ErrorUtils.handleError(it) } + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/db/Converters.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/db/Converters.kt new file mode 100644 index 000000000..11b3558b9 --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/db/Converters.kt @@ -0,0 +1,14 @@ +package xyz.flipchat.internal.db + +import androidx.room.TypeConverter +import xyz.flipchat.services.data.Member +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +internal object Converters { + @TypeConverter + fun membersToString(members: List) = Json.encodeToString(members) + + @TypeConverter + fun stringToMembers(value: String) = Json.decodeFromString>(value) +} \ No newline at end of file diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/db/FcAppDatabase.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/db/FcAppDatabase.kt new file mode 100644 index 000000000..93ce96d41 --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/db/FcAppDatabase.kt @@ -0,0 +1,197 @@ +package xyz.flipchat.internal.db + +import android.content.Context +import androidx.room.AutoMigration +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import androidx.room.migration.AutoMigrationSpec +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.getcode.services.db.ClosableDatabase +import com.getcode.services.db.SharedConverters +import com.getcode.services.model.PrefBool +import com.getcode.services.model.PrefDouble +import com.getcode.services.model.PrefInt +import com.getcode.services.model.PrefString +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import com.getcode.vendor.Base58 +import org.kin.sdk.base.tools.subByteArray +import timber.log.Timber +import xyz.flipchat.services.domain.model.chat.Conversation +import xyz.flipchat.services.domain.model.chat.ConversationMember +import xyz.flipchat.services.domain.model.chat.ConversationMessage +import xyz.flipchat.services.domain.model.chat.ConversationMessageTip +import xyz.flipchat.services.domain.model.chat.ConversationPointerCrossRef +import xyz.flipchat.services.internal.db.ConversationDao +import xyz.flipchat.services.internal.db.ConversationMemberDao +import xyz.flipchat.services.internal.db.ConversationMessageDao +import xyz.flipchat.services.internal.db.ConversationPointerDao +import java.io.File + +@Database( + entities = [ + PrefInt::class, + PrefString::class, + PrefBool::class, + PrefDouble::class, + Conversation::class, + ConversationMember::class, + ConversationPointerCrossRef::class, + ConversationMessage::class, + ConversationMessageTip::class, + ], + autoMigrations = [ + AutoMigration(from = 1, to = 2), + AutoMigration(from = 2, to = 3), + AutoMigration(from = 3, to = 4), + AutoMigration(from = 4, to = 5), + AutoMigration(from = 5, to = 6), + AutoMigration(from = 6, to = 7, spec = FcAppDatabase.Migration6To7::class), + AutoMigration(from = 7, to = 8, spec = FcAppDatabase.Migration7To8::class), + AutoMigration(from = 8, to = 9), + AutoMigration(from = 9, to = 10, spec = FcAppDatabase.Migration9To10::class), + AutoMigration(from = 10, to = 11), + AutoMigration(from = 11, to = 12, spec = FcAppDatabase.Migration11To12::class), + AutoMigration(from = 12, to = 13), + AutoMigration(from = 13, to = 14, spec = FcAppDatabase.Migration13To14::class), + AutoMigration(from = 14, to = 15, spec = FcAppDatabase.Migration14To15::class), + AutoMigration(from = 15, to = 16), + // explicit no migration to fallback to reset (from = 16, to = 17) + AutoMigration(from = 17, to = 18), + AutoMigration(from = 18, to = 19), + AutoMigration(from = 19, to = 20), + AutoMigration(from = 20, to = 21, spec = FcAppDatabase.Migration20To21::class), + AutoMigration(from = 21, to = 22, spec = FcAppDatabase.Migration21To22::class), + ], + version = 22, +) +@TypeConverters(SharedConverters::class, Converters::class) +abstract class FcAppDatabase : RoomDatabase(), ClosableDatabase { + abstract fun prefIntDao(): PrefIntDao + abstract fun prefStringDao(): PrefStringDao + abstract fun prefBoolDao(): PrefBoolDao + abstract fun prefDoubleDao(): PrefDoubleDao + + abstract fun conversationDao(): ConversationDao + abstract fun conversationPointersDao(): ConversationPointerDao + abstract fun conversationMessageDao(): ConversationMessageDao + abstract fun conversationMembersDao(): ConversationMemberDao + + class Migration6To7 : Migration(6, 7), AutoMigrationSpec { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DELETE FROM members") + } + } + + class Migration7To8 : Migration(7, 8), AutoMigrationSpec { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DELETE FROM conversations") + } + } + + class Migration9To10 : Migration(9, 10), AutoMigrationSpec { + override fun migrate(db: SupportSQLiteDatabase) { + // drop messages to allow proper mapping for announcements + db.execSQL("DELETE FROM messages") + } + } + + class Migration11To12 : Migration(11, 12), AutoMigrationSpec { + override fun migrate(db: SupportSQLiteDatabase) { + // Add indexes for messages + db.execSQL("CREATE INDEX IF NOT EXISTS index_messages_conversationIdBase58 ON messages(conversationIdBase58)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_messages_senderIdBase58 ON messages(senderIdBase58)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_messages_dateMillis ON messages(dateMillis)") + + // Add index for message_contents + db.execSQL("CREATE INDEX IF NOT EXISTS index_message_contents_messageIdBase58 ON message_contents(messageIdBase58)") + + // Add indexes for members + db.execSQL("CREATE INDEX IF NOT EXISTS index_members_memberIdBase58 ON members(memberIdBase58)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_members_conversationIdBase58 ON members(conversationIdBase58)") + } + } + + class Migration13To14 : Migration(13, 14), AutoMigrationSpec { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE members ADD COLUMN isFullMember INTEGER NOT NULL DEFAULT 0") + db.execSQL("UPDATE members SET isFullMember = 1") + } + } + + class Migration14To15 : Migration(14, 15), AutoMigrationSpec { + override fun migrate(db: SupportSQLiteDatabase) { + // drop messages to allow proper use of message ID as the timestamp + db.execSQL("DELETE FROM messages") + } + } + + class Migration20To21 : Migration(20, 21), AutoMigrationSpec { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DELETE FROM messages") + } + } + + class Migration21To22 : Migration(21, 22), AutoMigrationSpec { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DELETE FROM messages") + db.execSQL("DELETE FROM members") + } + } + + @Synchronized + override fun closeDb() { + if (instance != null) { + Timber.d("close") + instance?.close() + instance = null + } + } + + @Synchronized + override fun deleteDb(context: Context) { + Timber.d("delete") + closeDb() + if (dbName.isEmpty()) return + + val databases = File(context.applicationInfo.dataDir + "/databases") + val db = File(databases, dbName) + db.delete() + + val journal = File(databases, "$dbName-journal") + val shm = File(databases, "$dbName-shm") + val wal = File(databases, "$dbName-wal") + + if (journal.exists()) journal.delete() + if (shm.exists()) shm.delete() + if (wal.exists()) shm.delete() + } + + companion object { + private var instance: FcAppDatabase? = null + fun requireInstance() = requireNotNull(instance) + fun getInstance(): FcAppDatabase? = instance + private var dbName: String = "" + + private const val dbNamePrefix = "FcAppDatabase" + + fun isOpen() = instance?.isOpen == true + + fun init(context: Context, entropyB64: String) { + val dbUniqueName = Base58.encode(entropyB64.toByteArray().subByteArray(0, 6)) + trace("database init start $dbUniqueName", type = TraceType.Process) + instance?.close() + dbName = "$dbNamePrefix-$dbUniqueName.db" + + instance = + Room.databaseBuilder(context, FcAppDatabase::class.java, dbName) + .fallbackToDestructiveMigration() + .build() + + trace("database init end", type = TraceType.Process) + } + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/db/PrefBoolDao.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/db/PrefBoolDao.kt new file mode 100644 index 000000000..411e13ed5 --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/db/PrefBoolDao.kt @@ -0,0 +1,22 @@ +package xyz.flipchat.internal.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.getcode.services.model.PrefBool +import kotlinx.coroutines.flow.Flow + +@Dao +interface PrefBoolDao { + @Query("SELECT * FROM PrefBool WHERE key = :key") + suspend fun get(key: String): PrefBool? + @Query("SELECT * FROM PrefBool WHERE key = :key") + fun observe(key: String): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(item: PrefBool) + + @Query("DELETE FROM PrefBool") + suspend fun clear() +} \ No newline at end of file diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/db/PrefDoubleDao.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/db/PrefDoubleDao.kt new file mode 100644 index 000000000..1de0ba0bc --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/db/PrefDoubleDao.kt @@ -0,0 +1,24 @@ +package xyz.flipchat.internal.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.getcode.services.model.PrefDouble +import kotlinx.coroutines.flow.Flow + +@Dao +interface PrefDoubleDao { + @Query("SELECT * FROM PrefDouble WHERE `key` = :key") + suspend fun get(key: String): PrefDouble? + + @Query("SELECT * FROM PrefDouble WHERE key = :key") + fun observe(key: String): Flow + + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(item: PrefDouble) + + @Query("DELETE FROM PrefDouble") + suspend fun clear() +} \ No newline at end of file diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/db/PrefIntDao.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/db/PrefIntDao.kt new file mode 100644 index 000000000..0b8d4e82d --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/db/PrefIntDao.kt @@ -0,0 +1,23 @@ +package xyz.flipchat.internal.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.getcode.services.model.PrefInt +import kotlinx.coroutines.flow.Flow + +@Dao +interface PrefIntDao { + @Query("SELECT * FROM PrefInt WHERE key = :key") + suspend fun get(key: String): PrefInt? + + @Query("SELECT * FROM PrefInt WHERE key = :key") + fun observe(key: String): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(item: PrefInt) + + @Query("DELETE FROM PrefInt") + suspend fun clear() +} \ No newline at end of file diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/db/PrefStringDao.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/db/PrefStringDao.kt new file mode 100644 index 000000000..9dcd8fbe1 --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/db/PrefStringDao.kt @@ -0,0 +1,23 @@ +package xyz.flipchat.internal.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.getcode.services.model.PrefString +import kotlinx.coroutines.flow.Flow + +@Dao +interface PrefStringDao { + @Query("SELECT * FROM PrefString WHERE key = :key") + suspend fun get(key: String): PrefString? + + @Query("SELECT * FROM PrefString WHERE key = :key") + fun observe(key: String): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(item: PrefString) + + @Query("DELETE FROM PrefString") + suspend fun clear() +} \ No newline at end of file diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/inject/FlipchatServicesModule.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/inject/FlipchatServicesModule.kt new file mode 100644 index 000000000..39634b132 --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/inject/FlipchatServicesModule.kt @@ -0,0 +1,98 @@ +package xyz.flipchat.internal.inject + +import com.getcode.network.BalanceController +import com.getcode.network.api.TransactionApiV2 +import com.getcode.network.client.Client +import com.getcode.network.client.TransactionReceiver +import com.getcode.network.exchange.Exchange +import com.getcode.network.repository.AccountRepository +import com.getcode.network.repository.BalanceRepository +import com.getcode.network.repository.MessagingRepository +import com.getcode.network.repository.TransactionRepository +import com.getcode.services.db.CurrencyProvider +import com.getcode.services.network.core.NetworkOracle +import com.getcode.services.network.core.NetworkOracleImpl +import com.getcode.utils.CurrencyUtils +import com.getcode.utils.network.NetworkConnectivityListener +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import xyz.flipchat.services.user.UserManager +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object FlipchatServicesModule { + + @Provides + fun provideNetworkOracle(): NetworkOracle { + return NetworkOracleImpl() + } + + @Provides + fun providesOrganizerLookup(userManager: UserManager): () -> com.getcode.solana.organizer.Organizer? { + return { userManager.organizer } + } + + @Singleton + @Provides + fun provideClient( + userManager: UserManager, + transactionRepository: TransactionRepository, + messagingRepository: MessagingRepository, + accountRepository: AccountRepository, + balanceController: BalanceController, + transactionReceiver: TransactionReceiver, + exchange: Exchange, + networkObserver: NetworkConnectivityListener, + ): Client { + return Client( + userManager = userManager, + transactionRepository, + messagingRepository, + balanceController, + accountRepository, + exchange, + transactionReceiver, + networkObserver, + ) + } + + @Singleton + @Provides + fun provideBalanceController( + userManager: UserManager, + exchange: Exchange, + balanceRepository: BalanceRepository, + transactionRepository: TransactionRepository, + accountRepository: AccountRepository, + transactionReceiver: TransactionReceiver, + networkObserver: NetworkConnectivityListener, + currencyUtils: CurrencyUtils, + currencyProvider: CurrencyProvider, + ): BalanceController { + return BalanceController( + userManager = userManager, + exchange = exchange, + balanceRepository = balanceRepository, + transactionRepository = transactionRepository, + accountRepository = accountRepository, + transactionReceiver = transactionReceiver, + networkObserver = networkObserver, + getCurrencyFromCode = { + it?.name?.let(currencyUtils::getCurrency) + }, + suffix = { currency -> currencyProvider.suffix(currency) } + ) + } + + @Singleton + @Provides + fun provideTransactionRepository( + transactionApi: TransactionApiV2, + ): TransactionRepository { + return TransactionRepository(transactionApi) + } + +} \ No newline at end of file diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/preferences/PreferenceStore.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/preferences/PreferenceStore.kt new file mode 100644 index 000000000..5c4419f5c --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/preferences/PreferenceStore.kt @@ -0,0 +1,29 @@ +package xyz.flipchat.internal.preferences + +import com.getcode.services.model.InternalRouting +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import xyz.flipchat.internal.db.FcAppDatabase +import javax.inject.Inject + +internal class PreferenceStore @Inject constructor() { + + private val db: FcAppDatabase + get() = FcAppDatabase.requireInstance() + + fun observe(pref: FcPref, default: Boolean): Flow { + return db.prefBoolDao().observe(pref.key) + .map { it?.value ?: default } + } + + fun observe(pref: FcPref): Flow { + return db.prefBoolDao().observe(pref.key) + .map { it?.value } + } +} + +sealed class FcPref(val key: String) { + data object EligibleForAirdrop: FcPref("is_eligible_get_first_kin_airdrop"), + InternalRouting +} + diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/utils/String.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/utils/String.kt new file mode 100644 index 000000000..ad73c6a4c --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/internal/utils/String.kt @@ -0,0 +1,31 @@ +package xyz.flipchat.internal.utils + +fun String.addLeadingZero(upTo: Int): String { + if (upTo < length) return this + val padding = "0".repeat(length - upTo) + return "$padding$this" +} + +fun String.base64EncodedData(): ByteArray { + val data = toByteArray() + val r = data.size % 4 + if (r > 0) { + val requiredPadding = data.size + 4 - r + val padding = "=".repeat(requiredPadding) + return data + padding.toByteArray() + } + return data +} + +fun String.padded(minCount: Int): String { + return if (this.length < minCount) { + val toInsert = minCount - this.length + val padding = " ".repeat(toInsert) + this + padding + } else { + this + } +} + +typealias Base64String = String +typealias Base58String = String \ No newline at end of file diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/notifications/FcNotification.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/notifications/FcNotification.kt new file mode 100644 index 000000000..fe730dbf7 --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/notifications/FcNotification.kt @@ -0,0 +1,58 @@ +package xyz.flipchat.notifications + +import com.getcode.model.ID +import com.getcode.model.chat.MessageContent +import com.getcode.utils.base58 +import com.getcode.utils.decodeBase64 + +data class FcNotification( + val type: FcNotificationType, + val title: String, + val body: MessageContent, +) + +private enum class TypeValue { + Unknown, ChatMessage +} + +sealed interface FcNotificationType { + val ordinal: Int + val name: String + + sealed interface Notifiable + data object Unknown: FcNotificationType, Notifiable { + override val ordinal: Int = 99 + override val name: String = "Misc" + } + + data class ChatMessage(val id: ID?, val roomNumber: Long?, val sender: String?): FcNotificationType, Notifiable { + override val ordinal: Int = 1 + override val name: String = "Chat Messages" + } + + fun isNotifiable() = this is Notifiable + + companion object { + private const val TYPE = "type" + private const val CHAT_ID = "chat_id" + private const val SENDER = "sender" + + fun resolve(value: MutableMap): FcNotificationType { + val type = value[TYPE] + var notificationType = runCatching { TypeValue.valueOf(type.orEmpty()) }.getOrNull() + if (notificationType == null) { + // fallback to chat + notificationType = TypeValue.ChatMessage + } + + return when (notificationType) { + TypeValue.ChatMessage -> { + val chatId = value[CHAT_ID]?.decodeBase64()?.toList() + val sender = value[SENDER] + ChatMessage(chatId, null, sender) + } + else -> Unknown + } + } + } +} \ No newline at end of file diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/notifications/NotificationManager.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/notifications/NotificationManager.kt new file mode 100644 index 000000000..5b1f6b046 --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/notifications/NotificationManager.kt @@ -0,0 +1,23 @@ +package xyz.flipchat.notifications + +import androidx.core.app.NotificationManagerCompat +import com.getcode.model.ID +import com.getcode.utils.base58 + +fun NotificationManagerCompat.getRoomNotifications(roomId: ID, roomName: String): List { + val barNotifications = activeNotifications + val roomNotifications = barNotifications.mapNotNull { notification -> + val roomIdHash = roomId.base58.hashCode() + val roomNameHash = roomName.hashCode() + + val isMatch = notification.id == roomIdHash || notification.id == roomNameHash + + if (isMatch) { + notification.id + } else { + null + } + } + + return roomNotifications +} \ No newline at end of file diff --git a/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/notifications/NotificationParser.kt b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/notifications/NotificationParser.kt new file mode 100644 index 000000000..039829637 --- /dev/null +++ b/services/flipchat/sdk/src/main/kotlin/xyz/flipchat/notifications/NotificationParser.kt @@ -0,0 +1,25 @@ +package xyz.flipchat.notifications + +import com.getcode.model.chat.MessageContent +import com.getcode.utils.ErrorUtils +import com.google.firebase.messaging.RemoteMessage +import timber.log.Timber + +fun RemoteMessage.parse(): FcNotification? { + Timber.d("data=$data") + val type = FcNotificationType.resolve(data) + if (type == FcNotificationType.Unknown) { + ErrorUtils.handleError(Throwable("Unknown notification type")) + return null + } + + if (!type.isNotifiable()) return FcNotification(type, "", MessageContent.Localized("", false)) + + val title = data["title"] ?: notification?.title.orEmpty() + val body = data["body"] ?: notification?.body.orEmpty() + return FcNotification( + type, + title, + MessageContent.RawText(body, false) + ) +} \ No newline at end of file diff --git a/services/shared/.gitignore b/services/shared/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/services/shared/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/services/shared/build.gradle.kts b/services/shared/build.gradle.kts new file mode 100644 index 000000000..8811723c9 --- /dev/null +++ b/services/shared/build.gradle.kts @@ -0,0 +1,97 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_kapt) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.codeNamespace}.services.common" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + + consumerProguardFiles("consumer-rules.pro") + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } + + buildFeatures { + buildConfig = true + } +} + +dependencies { + api(project(":libs:datetime")) + api(project(":libs:crypto:kin")) + api(project(":libs:crypto:solana")) + api(project(":libs:currency")) + api(project(":libs:encryption:base58")) + api(project(":libs:encryption:ed25519")) + api(project(":libs:encryption:hmac")) + api(project(":libs:encryption:keys")) + api(project(":libs:encryption:mnemonic")) + api(project(":libs:encryption:sha256")) + api(project(":libs:encryption:sha512")) + api(project(":libs:encryption:utils")) + api(project(":libs:logging")) + api(project(":libs:models")) + api(project(":libs:network:exchange")) + api(project(":libs:network:connectivity")) + implementation(project(":ui:resources")) + + implementation(Libs.rxjava) + implementation(Libs.kotlinx_coroutines_core) + implementation(Libs.kotlinx_serialization_json) + implementation(Libs.inject) + + implementation(Libs.grpc_okhttp) + implementation(Libs.grpc_kotlin) + implementation(Libs.androidx_lifecycle_runtime) + implementation(Libs.androidx_room_runtime) + implementation(Libs.androidx_room_ktx) + implementation(Libs.androidx_room_rxjava3) + implementation(Libs.androidx_room_paging) + implementation(Libs.okhttp) + implementation(Libs.mixpanel) + + implementation(platform(Libs.firebase_bom)) + implementation(Libs.firebase_crashlytics) + implementation(Libs.firebase_installations) + implementation(Libs.firebase_perf) + implementation(Libs.firebase_messaging) + + implementation(Libs.play_integrity) + + implementation(Libs.androidx_paging_runtime) + + kapt(Libs.androidx_room_compiler) + implementation(Libs.sqlcipher) + + implementation(Libs.fingerprint_pro) + + implementation(Libs.lib_phone_number_google) + + androidTestImplementation(Libs.androidx_junit) + androidTestImplementation(Libs.junit) + androidTestImplementation(Libs.androidx_test_runner) + implementation(Libs.hilt) + + implementation(Libs.timber) + implementation(Libs.bugsnag) +} diff --git a/services/shared/consumer-rules.pro b/services/shared/consumer-rules.pro new file mode 100644 index 000000000..5f642a47c --- /dev/null +++ b/services/shared/consumer-rules.pro @@ -0,0 +1,6 @@ +# Needed to keep generic signatures +-keepattributes Signature + +-keepclasseswithmembernames class * { + native ; +} \ No newline at end of file diff --git a/services/shared/src/main/kotlin/com/getcode/services/ChannelConfig.kt b/services/shared/src/main/kotlin/com/getcode/services/ChannelConfig.kt new file mode 100644 index 000000000..4ee75d7b7 --- /dev/null +++ b/services/shared/src/main/kotlin/com/getcode/services/ChannelConfig.kt @@ -0,0 +1,15 @@ +package com.getcode.services + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +interface ChannelConfig { + val baseUrl: String + val port: Int + get() = 443 + val userAgent: String + val keepAlive: Duration + get() = 4.minutes + val keepAliveTimeout: Duration + get() = 1.minutes +} \ No newline at end of file diff --git a/services/shared/src/main/kotlin/com/getcode/services/analytics/AnalyticsService.kt b/services/shared/src/main/kotlin/com/getcode/services/analytics/AnalyticsService.kt new file mode 100644 index 000000000..2d4cb54b2 --- /dev/null +++ b/services/shared/src/main/kotlin/com/getcode/services/analytics/AnalyticsService.kt @@ -0,0 +1,13 @@ +package com.getcode.services.analytics + +interface AnalyticsService { + fun onAppStart() + fun onAppStarted() + fun unintentionalLogout() +} + +class AnalyticsServiceNull : AnalyticsService { + override fun onAppStart() = Unit + override fun onAppStarted() = Unit + override fun unintentionalLogout() = Unit +} \ No newline at end of file diff --git a/services/shared/src/main/kotlin/com/getcode/services/db/CurrencyProvider.kt b/services/shared/src/main/kotlin/com/getcode/services/db/CurrencyProvider.kt new file mode 100644 index 000000000..dd730d166 --- /dev/null +++ b/services/shared/src/main/kotlin/com/getcode/services/db/CurrencyProvider.kt @@ -0,0 +1,9 @@ +package com.getcode.services.db + +import com.getcode.model.Currency + +interface CurrencyProvider { + suspend fun preferredCurrency(): Currency? + suspend fun defaultCurrency(): Currency? + fun suffix(currency: Currency?): String +} \ No newline at end of file diff --git a/services/shared/src/main/kotlin/com/getcode/services/db/Database.kt b/services/shared/src/main/kotlin/com/getcode/services/db/Database.kt new file mode 100644 index 000000000..9d2461c18 --- /dev/null +++ b/services/shared/src/main/kotlin/com/getcode/services/db/Database.kt @@ -0,0 +1,31 @@ +package com.getcode.services.db + +import android.content.Context +import timber.log.Timber + +interface ClosableDatabase { + fun closeDb() + fun deleteDb(context: Context) +} + +object Database { + + private val instances = mutableListOf() + + fun register(database: ClosableDatabase) { + instances += database + } + + fun close() { + instances.onEach { + it.closeDb() + } + } + + fun delete(context: Context) { + instances.onEach { + it.deleteDb(context) + } + instances.clear() + } +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/db/Converters.kt b/services/shared/src/main/kotlin/com/getcode/services/db/SharedConverters.kt similarity index 73% rename from api/src/main/java/com/getcode/db/Converters.kt rename to services/shared/src/main/kotlin/com/getcode/services/db/SharedConverters.kt index 067123b27..da891086a 100644 --- a/api/src/main/java/com/getcode/db/Converters.kt +++ b/services/shared/src/main/kotlin/com/getcode/services/db/SharedConverters.kt @@ -1,19 +1,17 @@ -package com.getcode.db +package com.getcode.services.db import androidx.room.TypeConverter import com.getcode.model.CurrencyCode import com.getcode.model.KinAmount import com.getcode.model.Rate -import com.getcode.model.chat.ChatMember import com.getcode.model.chat.MessageContent import com.getcode.model.chat.Pointer -import com.getcode.network.repository.decodeBase64 -import com.getcode.network.repository.encodeBase64 -import kotlinx.serialization.decodeFromString +import com.getcode.utils.decodeBase64 +import com.getcode.utils.encodeBase64 import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -class Converters { +class SharedConverters { @TypeConverter fun stringToByteList(value: String): List = value.decodeBase64().toList() @@ -47,18 +45,6 @@ class Converters { @TypeConverter fun stringToKinAmount(value: String) = Json.decodeFromString(KinAmount.serializer(), value) - @TypeConverter - fun chatMemberToString(member: ChatMember) = Json.encodeToString(ChatMember.serializer(), member) - - @TypeConverter - fun stringToChatMember(value: String) = Json.decodeFromString(ChatMember.serializer(), value) - - @TypeConverter - fun chatMembersToString(members: List) = Json.encodeToString(members) - - @TypeConverter - fun stringToChatMembers(value: String) = Json.decodeFromString>(value) - @TypeConverter fun pointerToString(pointer: Pointer) = Json.encodeToString(pointer) diff --git a/services/shared/src/main/kotlin/com/getcode/services/generator/Generator.kt b/services/shared/src/main/kotlin/com/getcode/services/generator/Generator.kt new file mode 100644 index 000000000..871e365e6 --- /dev/null +++ b/services/shared/src/main/kotlin/com/getcode/services/generator/Generator.kt @@ -0,0 +1,5 @@ +package com.getcode.services.generator + +interface Generator { + fun generate(predicate: D): R +} \ No newline at end of file diff --git a/services/shared/src/main/kotlin/com/getcode/services/generator/MnemonicGenerator.kt b/services/shared/src/main/kotlin/com/getcode/services/generator/MnemonicGenerator.kt new file mode 100644 index 000000000..27bd7bc80 --- /dev/null +++ b/services/shared/src/main/kotlin/com/getcode/services/generator/MnemonicGenerator.kt @@ -0,0 +1,17 @@ +package com.getcode.services.generator + +import com.getcode.crypt.MnemonicPhrase +import com.getcode.services.utils.Base58String +import com.getcode.services.utils.Base64String +import javax.inject.Inject + +class MnemonicGenerator @Inject constructor(): Generator { + + override fun generate(predicate: Base64String): MnemonicPhrase { + return MnemonicPhrase.fromEntropyB64(predicate) + } + + fun generateFromBase58(predicate: Base58String): MnemonicPhrase { + return MnemonicPhrase.fromEntropyB58(predicate) + } +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/manager/MnemonicManager.kt b/services/shared/src/main/kotlin/com/getcode/services/manager/MnemonicManager.kt similarity index 85% rename from api/src/main/java/com/getcode/manager/MnemonicManager.kt rename to services/shared/src/main/kotlin/com/getcode/services/manager/MnemonicManager.kt index e8db6b0ea..7f2a54632 100644 --- a/api/src/main/java/com/getcode/manager/MnemonicManager.kt +++ b/services/shared/src/main/kotlin/com/getcode/services/manager/MnemonicManager.kt @@ -1,12 +1,12 @@ -package com.getcode.manager +package com.getcode.services.manager import com.getcode.crypt.MnemonicCache import com.getcode.crypt.MnemonicCode import com.getcode.crypt.MnemonicPhrase import com.getcode.ed25519.Ed25519.KeyPair -import com.getcode.generator.MnemonicGenerator -import com.getcode.utils.Base58String -import com.getcode.utils.Base64String +import com.getcode.services.generator.MnemonicGenerator +import com.getcode.services.utils.Base58String +import com.getcode.services.utils.Base64String import javax.inject.Inject class MnemonicManager @Inject constructor( diff --git a/services/shared/src/main/kotlin/com/getcode/services/manager/ModalManager.kt b/services/shared/src/main/kotlin/com/getcode/services/manager/ModalManager.kt new file mode 100644 index 000000000..8ff461a5c --- /dev/null +++ b/services/shared/src/main/kotlin/com/getcode/services/manager/ModalManager.kt @@ -0,0 +1,63 @@ +package com.getcode.services.manager + +import androidx.annotation.DrawableRes +import com.getcode.services.manager.ModalManager.ActionType +import com.getcode.services.manager.ModalManager.Message +import com.getcode.services.manager.ModalManager.MessageType +import com.getcode.services.manager.ModalManager._messages +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.util.UUID + +object ModalManager { + data class Message( + @DrawableRes + val icon: Int? = null, + val title: String, + val subtitle: String = "", + val positiveText: String, + val negativeText: String? = null, + val tertiaryText: String? = null, + val onPositive: () -> Unit, + val onNegative: () -> Unit = {}, + val onTertiary: () -> Unit = {}, + val onClose: (actionType: ActionType?) -> Unit = {}, + val type: MessageType = MessageType.DEFAULT, +// val isDismissibleByTouchOutside: Boolean = true, + val isDismissibleByBackButton: Boolean = true, + val timeoutSeconds: Int? = null, + val id: Long = UUID.randomUUID().mostSignificantBits, + ) + + private val _messages: MutableStateFlow> = MutableStateFlow( + listOf() + ) + val messages: StateFlow> get() = _messages.asStateFlow() + + fun showMessage(message: Message) { + _messages.update { currentMessages -> + currentMessages + message + } + } + + fun setMessageShown(messageId: Long) { + _messages.update { currentMessages -> + currentMessages.filterNot { it.id == messageId } + } + } + + fun clear() = _messages.update { listOf() } + + fun clearByType(type: MessageType) = _messages.update { it.filterNot { m -> m.type == type } } + + enum class MessageType { DEFAULT } + + enum class ActionType { + Positive, + Negative, + Tertiary + } + +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/mapper/Mapper.kt b/services/shared/src/main/kotlin/com/getcode/services/mapper/Mapper.kt similarity index 76% rename from api/src/main/java/com/getcode/mapper/Mapper.kt rename to services/shared/src/main/kotlin/com/getcode/services/mapper/Mapper.kt index 92b37dd3b..a3f9d0af3 100644 --- a/api/src/main/java/com/getcode/mapper/Mapper.kt +++ b/services/shared/src/main/kotlin/com/getcode/services/mapper/Mapper.kt @@ -1,4 +1,4 @@ -package com.getcode.mapper +package com.getcode.services.mapper interface Mapper { fun map(from: F): T diff --git a/api/src/main/java/com/getcode/model/CodePayload.kt b/services/shared/src/main/kotlin/com/getcode/services/model/CodePayload.kt similarity index 95% rename from api/src/main/java/com/getcode/model/CodePayload.kt rename to services/shared/src/main/kotlin/com/getcode/services/model/CodePayload.kt index 9ad8bab48..b1f7b339b 100644 --- a/api/src/main/java/com/getcode/model/CodePayload.kt +++ b/services/shared/src/main/kotlin/com/getcode/services/model/CodePayload.kt @@ -1,13 +1,18 @@ -package com.getcode.model +package com.getcode.services.model import com.getcode.codeScanner.CodeScanner import com.getcode.crypt.Sha256Hash import com.getcode.ed25519.Ed25519.KeyPair -import com.getcode.network.repository.encodeBase64 +import com.getcode.model.CurrencyCode +import com.getcode.model.Fiat +import com.getcode.model.Kin +import com.getcode.model.Value +import com.getcode.services.model.payload.Username import com.getcode.utils.DataSlice.byteToUnsignedInt import com.getcode.utils.DataSlice.suffix import com.getcode.utils.DataSlice.toLong -import com.getcode.utils.deriveRendezvousKey +import com.getcode.services.utils.deriveRendezvousKey +import com.getcode.utils.encodeBase64 import org.kin.sdk.base.tools.byteArrayToLong import org.kin.sdk.base.tools.longToByteArray import java.nio.ByteBuffer @@ -24,6 +29,7 @@ data class CodePayload( is Fiat -> deriveRendezvousKey(encode(kind = kind, fiat = value, nonce = nonce).toByteArray()) is Kin -> deriveRendezvousKey(encode(kind = kind, kin = value, nonce = nonce).toByteArray()) is Username -> deriveRendezvousKey(encode(kind = kind, username = value).toByteArray()) + else -> throw IllegalArgumentException() } } @@ -50,6 +56,7 @@ data class CodePayload( is Kin -> encode(kind, value, nonce) is Fiat -> encode(kind, value, nonce) is Username -> encode(kind, value) + else -> throw IllegalArgumentException() } } @@ -182,7 +189,7 @@ enum class Kind(val value: Int) { RequestPayment(2), Login(3), RequestPaymentV2(4), - Tip(5) + Tip(5), } /* diff --git a/services/shared/src/main/kotlin/com/getcode/services/model/ExtendedMetadata.kt b/services/shared/src/main/kotlin/com/getcode/services/model/ExtendedMetadata.kt new file mode 100644 index 000000000..43e4c963c --- /dev/null +++ b/services/shared/src/main/kotlin/com/getcode/services/model/ExtendedMetadata.kt @@ -0,0 +1,27 @@ +package com.getcode.services.model + +import com.getcode.model.SocialUser + +sealed interface ExtendedMetadata { + data class Tip(val socialUser: SocialUser): ExtendedMetadata + data class Any(val data: ByteArray, val typeUrl: String): ExtendedMetadata { + override fun equals(other: kotlin.Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Any + + if (!data.contentEquals(other.data)) return false + if (typeUrl != other.typeUrl) return false + + return true + } + + override fun hashCode(): Int { + var result = data.contentHashCode() + result = 31 * result + typeUrl.hashCode() + return result + } + + } +} \ No newline at end of file diff --git a/services/shared/src/main/kotlin/com/getcode/services/model/PrefBool.kt b/services/shared/src/main/kotlin/com/getcode/services/model/PrefBool.kt new file mode 100644 index 000000000..4388fd439 --- /dev/null +++ b/services/shared/src/main/kotlin/com/getcode/services/model/PrefBool.kt @@ -0,0 +1,25 @@ +@file:Suppress("ClassName") + +package com.getcode.services.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class PrefBool( + @PrimaryKey val key: String, + val value: Boolean +) + +// Used internally to control logic and UI +interface InternalRouting +// Beta flag exposed in Settings -> Beta Flags to enable bleeding edge features +interface BetaFlag +// Dev settings +interface DevSetting +// This removes it from the UI in Settings -> Beta Flags +interface Immutable +// Once a feature behind a beta flag is made public, it becomes immutable +interface Launched: Immutable +// A feature flag can also be deemed deprecated and is also then immutable +interface Deprecated : Immutable diff --git a/api/src/main/java/com/getcode/model/PrefDouble.kt b/services/shared/src/main/kotlin/com/getcode/services/model/PrefDouble.kt similarity index 80% rename from api/src/main/java/com/getcode/model/PrefDouble.kt rename to services/shared/src/main/kotlin/com/getcode/services/model/PrefDouble.kt index 543b6b65c..fa36cf8ac 100644 --- a/api/src/main/java/com/getcode/model/PrefDouble.kt +++ b/services/shared/src/main/kotlin/com/getcode/services/model/PrefDouble.kt @@ -1,4 +1,4 @@ -package com.getcode.model +package com.getcode.services.model import androidx.room.Entity import androidx.room.PrimaryKey diff --git a/api/src/main/java/com/getcode/model/PrefInt.kt b/services/shared/src/main/kotlin/com/getcode/services/model/PrefInt.kt similarity index 87% rename from api/src/main/java/com/getcode/model/PrefInt.kt rename to services/shared/src/main/kotlin/com/getcode/services/model/PrefInt.kt index 92fb407bc..993665bb0 100644 --- a/api/src/main/java/com/getcode/model/PrefInt.kt +++ b/services/shared/src/main/kotlin/com/getcode/services/model/PrefInt.kt @@ -1,4 +1,4 @@ -package com.getcode.model +package com.getcode.services.model import androidx.room.Entity import androidx.room.PrimaryKey diff --git a/api/src/main/java/com/getcode/model/PrefString.kt b/services/shared/src/main/kotlin/com/getcode/services/model/PrefString.kt similarity index 93% rename from api/src/main/java/com/getcode/model/PrefString.kt rename to services/shared/src/main/kotlin/com/getcode/services/model/PrefString.kt index 1250570b1..912bcbf41 100644 --- a/api/src/main/java/com/getcode/model/PrefString.kt +++ b/services/shared/src/main/kotlin/com/getcode/services/model/PrefString.kt @@ -1,4 +1,4 @@ -package com.getcode.model +package com.getcode.services.model import androidx.room.Entity import androidx.room.PrimaryKey diff --git a/services/shared/src/main/kotlin/com/getcode/services/model/chat/OutgoingMessageContent.kt b/services/shared/src/main/kotlin/com/getcode/services/model/chat/OutgoingMessageContent.kt new file mode 100644 index 000000000..0993c3d80 --- /dev/null +++ b/services/shared/src/main/kotlin/com/getcode/services/model/chat/OutgoingMessageContent.kt @@ -0,0 +1,12 @@ +package com.getcode.services.model.chat + +import com.getcode.model.ID +import com.getcode.model.KinAmount + +sealed interface OutgoingMessageContent { + data class Text(val text: String, val intentId: ID? = null): OutgoingMessageContent + data class Reply(val messageId: ID, val text: String): OutgoingMessageContent + data class Reaction(val messageId: ID, val emoji: String): OutgoingMessageContent + data class Tip(val messageId: ID, val amount: KinAmount, val intentId: ID): OutgoingMessageContent + data class DeleteRequest(val messageId: ID): OutgoingMessageContent +} \ No newline at end of file diff --git a/services/shared/src/main/kotlin/com/getcode/services/model/payload/Username.kt b/services/shared/src/main/kotlin/com/getcode/services/model/payload/Username.kt new file mode 100644 index 000000000..48b6562d2 --- /dev/null +++ b/services/shared/src/main/kotlin/com/getcode/services/model/payload/Username.kt @@ -0,0 +1,5 @@ +package com.getcode.services.model.payload + +import com.getcode.model.Value + +data class Username(val value: String): Value \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/core/GrpcApi.kt b/services/shared/src/main/kotlin/com/getcode/services/network/core/GrpcApi.kt similarity index 97% rename from api/src/main/java/com/getcode/network/core/GrpcApi.kt rename to services/shared/src/main/kotlin/com/getcode/services/network/core/GrpcApi.kt index e41b7a612..789a6d3fe 100644 --- a/api/src/main/java/com/getcode/network/core/GrpcApi.kt +++ b/services/shared/src/main/kotlin/com/getcode/services/network/core/GrpcApi.kt @@ -1,4 +1,4 @@ -package com.getcode.network.core +package com.getcode.services.network.core import io.grpc.ManagedChannel import io.grpc.stub.ClientCallStreamObserver @@ -7,11 +7,10 @@ import io.grpc.stub.StreamObserver import io.reactivex.rxjava3.core.BackpressureStrategy import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.reactive.asFlow -import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.reflect.KFunction2 abstract class GrpcApi(protected val managedChannel: ManagedChannel) { @@ -137,3 +136,4 @@ internal fun KFunction2 { return internalCallAsCancellableFlowable(request, backpressureStrategy).asFlow() } + diff --git a/api/src/main/java/com/getcode/network/core/NetworkOracle.kt b/services/shared/src/main/kotlin/com/getcode/services/network/core/NetworkOracle.kt similarity index 96% rename from api/src/main/java/com/getcode/network/core/NetworkOracle.kt rename to services/shared/src/main/kotlin/com/getcode/services/network/core/NetworkOracle.kt index 0973a6eab..6d6aa90e0 100644 --- a/api/src/main/java/com/getcode/network/core/NetworkOracle.kt +++ b/services/shared/src/main/kotlin/com/getcode/services/network/core/NetworkOracle.kt @@ -1,9 +1,8 @@ -package com.getcode.network.core +package com.getcode.services.network.core import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOn diff --git a/api/src/main/java/com/getcode/network/core/StreamObservers.kt b/services/shared/src/main/kotlin/com/getcode/services/observers/StreamObservers.kt similarity index 77% rename from api/src/main/java/com/getcode/network/core/StreamObservers.kt rename to services/shared/src/main/kotlin/com/getcode/services/observers/StreamObservers.kt index d0b2e2a66..57ed60c8a 100644 --- a/api/src/main/java/com/getcode/network/core/StreamObservers.kt +++ b/services/shared/src/main/kotlin/com/getcode/services/observers/StreamObservers.kt @@ -1,10 +1,11 @@ -package com.getcode.network.core +package com.getcode.services.observers import com.getcode.utils.TraceType import com.getcode.utils.trace import io.grpc.stub.StreamObserver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -13,6 +14,9 @@ import timber.log.Timber class BidirectionalStreamReference(private val scope: CoroutineScope) : AutoCloseable { + private val supervisorJob = SupervisorJob() + val coroutineScope = CoroutineScope(supervisorJob + scope.coroutineContext) + var stream: StreamObserver? = null set(value) { field = value @@ -25,6 +29,8 @@ class BidirectionalStreamReference(private val scope: Corouti var timeoutHandler: (() -> Unit)? = null + var onConnect: (() -> Unit)? = null + private var lastPing: Long? = null private var pingTimeout = 15_000L @@ -38,6 +44,10 @@ class BidirectionalStreamReference(private val scope: Corouti } fun receivedPing(updatedTimeout: Long? = null) { + if (lastPing == null) { + onConnect?.invoke() + } + lastPing = System.currentTimeMillis() // if the server provides a timeout, we'll update our local timeout accordingly. @@ -45,7 +55,10 @@ class BidirectionalStreamReference(private val scope: Corouti // double provided timeout val newTimeout = (it * 2) if (pingTimeout != newTimeout) { - trace(type = TraceType.StateChange, message = "Updating timeout from $pingTimeout to $newTimeout") + trace( + type = TraceType.StateChange, + message = "Updating timeout from $pingTimeout to $newTimeout" + ) pingTimeout = newTimeout } } @@ -60,7 +73,7 @@ class BidirectionalStreamReference(private val scope: Corouti fun postponeTimeout() { cancelTimeout() - timeoutTask = scope.launch { + timeoutTask = coroutineScope.launch { if (!isActive) return@launch delay(pingTimeout) @@ -71,7 +84,9 @@ class BidirectionalStreamReference(private val scope: Corouti } fun destroy() { + lastPing = null timeoutHandler = null + onConnect = null cancelTimeout() cancel() release() diff --git a/api/src/main/java/com/getcode/utils/CloudMessaging.kt b/services/shared/src/main/kotlin/com/getcode/services/utils/CloudMessaging.kt similarity index 86% rename from api/src/main/java/com/getcode/utils/CloudMessaging.kt rename to services/shared/src/main/kotlin/com/getcode/services/utils/CloudMessaging.kt index d2c5f77f8..b6c7fd0aa 100644 --- a/api/src/main/java/com/getcode/utils/CloudMessaging.kt +++ b/services/shared/src/main/kotlin/com/getcode/services/utils/CloudMessaging.kt @@ -1,5 +1,6 @@ -package com.getcode.utils +package com.getcode.services.utils +import com.getcode.utils.ErrorUtils import com.google.firebase.messaging.FirebaseMessaging import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume diff --git a/services/shared/src/main/kotlin/com/getcode/services/utils/Completable.kt b/services/shared/src/main/kotlin/com/getcode/services/utils/Completable.kt new file mode 100644 index 000000000..a3908bbab --- /dev/null +++ b/services/shared/src/main/kotlin/com/getcode/services/utils/Completable.kt @@ -0,0 +1,12 @@ +package com.getcode.services.utils + +import io.reactivex.rxjava3.core.Completable + +fun Completable.toKotlinResult(): Result { + return try { + this.blockingAwait() + Result.success(Unit) + } catch (e: Throwable) { + Result.failure(e) + } +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/utils/Double.kt b/services/shared/src/main/kotlin/com/getcode/services/utils/Double.kt similarity index 87% rename from api/src/main/java/com/getcode/utils/Double.kt rename to services/shared/src/main/kotlin/com/getcode/services/utils/Double.kt index 1c0d811b9..b61af1a09 100644 --- a/api/src/main/java/com/getcode/utils/Double.kt +++ b/services/shared/src/main/kotlin/com/getcode/services/utils/Double.kt @@ -1,4 +1,4 @@ -package com.getcode.utils +package com.getcode.services.utils fun Double.toByteArray(): ByteArray { diff --git a/api/src/main/java/com/getcode/utils/Flow.kt b/services/shared/src/main/kotlin/com/getcode/services/utils/Flow.kt similarity index 99% rename from api/src/main/java/com/getcode/utils/Flow.kt rename to services/shared/src/main/kotlin/com/getcode/services/utils/Flow.kt index 0eb171f7a..0142f3981 100644 --- a/api/src/main/java/com/getcode/utils/Flow.kt +++ b/services/shared/src/main/kotlin/com/getcode/services/utils/Flow.kt @@ -1,6 +1,6 @@ @file:Suppress("UNCHECKED_CAST") -package com.getcode.utils +package com.getcode.services.utils import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow diff --git a/api/src/main/java/com/getcode/utils/Installations.kt b/services/shared/src/main/kotlin/com/getcode/services/utils/Installations.kt similarity index 86% rename from api/src/main/java/com/getcode/utils/Installations.kt rename to services/shared/src/main/kotlin/com/getcode/services/utils/Installations.kt index 6f447d367..9d22f7980 100644 --- a/api/src/main/java/com/getcode/utils/Installations.kt +++ b/services/shared/src/main/kotlin/com/getcode/services/utils/Installations.kt @@ -1,5 +1,6 @@ -package com.getcode.utils +package com.getcode.services.utils +import com.getcode.utils.ErrorUtils import com.google.firebase.installations.FirebaseInstallations import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume diff --git a/api/src/main/java/com/getcode/utils/KeyPair.kt b/services/shared/src/main/kotlin/com/getcode/services/utils/KeyPair.kt similarity index 89% rename from api/src/main/java/com/getcode/utils/KeyPair.kt rename to services/shared/src/main/kotlin/com/getcode/services/utils/KeyPair.kt index 6dd0f81dc..35660a562 100644 --- a/api/src/main/java/com/getcode/utils/KeyPair.kt +++ b/services/shared/src/main/kotlin/com/getcode/services/utils/KeyPair.kt @@ -1,4 +1,4 @@ -package com.getcode.utils +package com.getcode.services.utils import android.util.Base64 import com.getcode.crypt.Sha256Hash diff --git a/api/src/main/java/com/getcode/utils/Long.kt b/services/shared/src/main/kotlin/com/getcode/services/utils/Long.kt similarity index 84% rename from api/src/main/java/com/getcode/utils/Long.kt rename to services/shared/src/main/kotlin/com/getcode/services/utils/Long.kt index 49a7525bd..3f6552a40 100644 --- a/api/src/main/java/com/getcode/utils/Long.kt +++ b/services/shared/src/main/kotlin/com/getcode/services/utils/Long.kt @@ -1,4 +1,4 @@ -package com.getcode.utils +package com.getcode.services.utils import kotlin.math.floor diff --git a/api/src/main/java/com/getcode/utils/Maps.kt b/services/shared/src/main/kotlin/com/getcode/services/utils/Maps.kt similarity index 89% rename from api/src/main/java/com/getcode/utils/Maps.kt rename to services/shared/src/main/kotlin/com/getcode/services/utils/Maps.kt index be09ea430..06c84fa64 100644 --- a/api/src/main/java/com/getcode/utils/Maps.kt +++ b/services/shared/src/main/kotlin/com/getcode/services/utils/Maps.kt @@ -1,4 +1,4 @@ -package com.getcode.utils +package com.getcode.services.utils inline fun MutableMap.getOrPutIfNonNull(key: K, defaultValue: () -> V?): V? { val value = get(key) diff --git a/api/src/main/java/com/getcode/utils/Nonce.kt b/services/shared/src/main/kotlin/com/getcode/services/utils/Nonce.kt similarity index 70% rename from api/src/main/java/com/getcode/utils/Nonce.kt rename to services/shared/src/main/kotlin/com/getcode/services/utils/Nonce.kt index 1202e639b..061ac810e 100644 --- a/api/src/main/java/com/getcode/utils/Nonce.kt +++ b/services/shared/src/main/kotlin/com/getcode/services/utils/Nonce.kt @@ -1,4 +1,4 @@ -package com.getcode.utils +package com.getcode.services.utils import kotlin.random.Random diff --git a/services/shared/src/main/kotlin/com/getcode/services/utils/Result.kt b/services/shared/src/main/kotlin/com/getcode/services/utils/Result.kt new file mode 100644 index 000000000..6e4b2e1d8 --- /dev/null +++ b/services/shared/src/main/kotlin/com/getcode/services/utils/Result.kt @@ -0,0 +1,39 @@ +package com.getcode.services.utils + +import kotlinx.coroutines.delay +import kotlin.time.Duration + +suspend fun Result.mapResult(transform: suspend (T) -> Result): Result { + return try { + this.fold( + onSuccess = { value -> transform(value) }, + onFailure = { error -> Result.failure(error) } + ) + } catch (e: Throwable) { + Result.failure(e) + } +} + +suspend fun Result.onSuccessWithDelay(minimumDelay: Long, block: suspend (T) -> Unit): Result { + val startTime = System.currentTimeMillis() + return onSuccess { value -> + val elapsedTime = System.currentTimeMillis() - startTime + val remainingTime = minimumDelay - elapsedTime + if (remainingTime > 0) { + delay(remainingTime) + } + block(value) + } +} + +suspend fun Result.onSuccessWithDelay(minimumDelay: Duration, block: suspend (T) -> Unit): Result { + val startTime = System.currentTimeMillis() + return onSuccess { value -> + val elapsedTime = System.currentTimeMillis() - startTime + val remainingTime = minimumDelay.inWholeMilliseconds - elapsedTime + if (remainingTime > 0) { + delay(remainingTime) + } + block(value) + } +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/utils/String.kt b/services/shared/src/main/kotlin/com/getcode/services/utils/String.kt similarity index 94% rename from api/src/main/java/com/getcode/utils/String.kt rename to services/shared/src/main/kotlin/com/getcode/services/utils/String.kt index 1180ef6f4..783cb071f 100644 --- a/api/src/main/java/com/getcode/utils/String.kt +++ b/services/shared/src/main/kotlin/com/getcode/services/utils/String.kt @@ -1,5 +1,6 @@ -package com.getcode.utils +package com.getcode.services.utils +import com.getcode.utils.ErrorUtils import com.google.i18n.phonenumbers.PhoneNumberUtil import java.util.Locale diff --git a/services/shared/src/main/kotlin/com/getcode/services/utils/logging/LoggingClientCall.kt b/services/shared/src/main/kotlin/com/getcode/services/utils/logging/LoggingClientCall.kt new file mode 100644 index 000000000..ae9153ab9 --- /dev/null +++ b/services/shared/src/main/kotlin/com/getcode/services/utils/logging/LoggingClientCall.kt @@ -0,0 +1,21 @@ +package com.getcode.services.utils.logging + +import io.grpc.ClientCall +import io.grpc.ForwardingClientCall.SimpleForwardingClientCall +import java.util.Queue + +class LoggingClientCall( + delegate: ClientCall, + private val requestQueue: Queue, + private val responseQueue: Queue, +): SimpleForwardingClientCall(delegate) { + + override fun sendMessage(message: ReqT) { + requestQueue.offer(message) + super.sendMessage(message) + } + + override fun start(responseListener: Listener, headers: io.grpc.Metadata) { + super.start(LoggingClientCallListener(responseListener, requestQueue, responseQueue), headers) + } +} \ No newline at end of file diff --git a/services/shared/src/main/kotlin/com/getcode/services/utils/logging/LoggingClientCallListener.kt b/services/shared/src/main/kotlin/com/getcode/services/utils/logging/LoggingClientCallListener.kt new file mode 100644 index 000000000..5d3d35403 --- /dev/null +++ b/services/shared/src/main/kotlin/com/getcode/services/utils/logging/LoggingClientCallListener.kt @@ -0,0 +1,69 @@ +package com.getcode.services.utils.logging + +import io.grpc.ClientCall +import io.grpc.ForwardingClientCallListener +import io.grpc.Status +import java.util.Queue + +class LoggingClientCallListener( + delegate: ClientCall.Listener, + private val requestQueue: Queue, + private val responseQueue: Queue +) : ForwardingClientCallListener.SimpleForwardingClientCallListener(delegate) { + + override fun onMessage(message: ResT) { + responseQueue.offer(message) + super.onMessage(message) + } + + override fun onClose(status: Status, trailers: io.grpc.Metadata?) { + val requestLog = requestQueue.joinToString(separator = ", ", prefix = "[", postfix = "]") { getObjectDetails(it) } + val responseLog = responseQueue.joinToString(separator = ", ", prefix = "[", postfix = "]") { getObjectDetails(it) } + + if (status.isOk) { + println("Request: $requestLog") + println("Response: $responseLog") + println("The request was processed successfully") + } else if (UNSUCCESSFUL_STATUS_CODES.contains(status.code)) { + println("Request: $requestLog") + println("An error occurred while processing the request: ${status.asRuntimeException()}") + } + + super.onClose(status, trailers) + } + + private fun getObjectDetails(obj: Any?, maxDepth: Int = 2, currentDepth: Int = 0): String { + if (obj == null) return "null" + + // Prevents infinitely deep recursion by setting a maximum depth + if (currentDepth >= maxDepth) return "${obj::class.java.simpleName}(...)" + + return try { + val fields = obj::class.java.declaredFields + fields.joinToString(", ", "${obj::class.java.simpleName}(", ")") { field -> + field.isAccessible = true + val value = field.get(obj) + + // Check if the field itself is another complex object (not a primitive or string), then call recursively + val valueString = when { + value == null -> "null" + value::class.java.isPrimitive || value is String -> value.toString() + else -> getObjectDetails(value, maxDepth, currentDepth + 1) + } + + "${field.name}=$valueString" + } + } catch (e: Exception) { + "Unable to log details for ${obj::class.java.simpleName}" + } + } + + + companion object { + private val UNSUCCESSFUL_STATUS_CODES = listOf( + Status.INVALID_ARGUMENT.code, + Status.INTERNAL.code, + Status.NOT_FOUND.code + ) + } +} \ No newline at end of file diff --git a/services/shared/src/main/kotlin/com/getcode/services/utils/logging/LoggingClientInterceptor.kt b/services/shared/src/main/kotlin/com/getcode/services/utils/logging/LoggingClientInterceptor.kt new file mode 100644 index 000000000..70e3f50d5 --- /dev/null +++ b/services/shared/src/main/kotlin/com/getcode/services/utils/logging/LoggingClientInterceptor.kt @@ -0,0 +1,19 @@ +package com.getcode.services.utils.logging + +import io.grpc.CallOptions +import io.grpc.Channel +import io.grpc.ClientCall +import io.grpc.ClientInterceptor +import io.grpc.MethodDescriptor +import java.util.concurrent.LinkedBlockingDeque + +class LoggingClientInterceptor: ClientInterceptor { + override fun interceptCall( + method: MethodDescriptor?, + callOptions: CallOptions?, + next: Channel + ): ClientCall { + return LoggingClientCall(next.newCall(method, callOptions), LinkedBlockingDeque(), LinkedBlockingDeque()) + } + +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 2d2146fc0..142c0a268 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,12 +1,58 @@ -include ':api' -include ':app' -include ':crypto:kin' -include ':crypto:ed25519' -include ':common:resources' -include ':service:models' -include ':service:protos' -include ':common:components' -include ':common:theme' -include ':vendor:tipkit:tipkit' -include ':vendor:tipkit:tipkit-m2' rootProject.name = "Code" + +include( + // apps + ":app", + ":flipchatApp", + + // protobuf model and service implementations for Code + ":definitions:code:models", + ":definitions:code:protos", + ":definitions:code-vm:models", + ":definitions:code-vm:protos", + ":definitions:flipchat:models", + ":definitions:flipchat:protos", + + // Internal libs + ":libs:crypto:kin", + ":libs:crypto:solana", + ":libs:currency", + ":libs:datetime", + ":libs:encryption:base58", + ":libs:encryption:ed25519", + ":libs:encryption:hmac", + ":libs:encryption:keys", + ":libs:encryption:mnemonic", + ":libs:encryption:sha256", + ":libs:encryption:sha512", + ":libs:encryption:utils", + ":libs:locale", + ":libs:logging", + ":libs:messaging", + ':libs:models', + ":libs:network:exchange", + ':libs:opengraph', + ':libs:network:connectivity', + ":libs:permissions", + ":libs:quickresponse", + ":libs:requests", + ":libs:vibrator", + + // Services definition for app and lib access + ":services:shared", + ":services:code", + ':services:flipchat:core', + ":services:flipchat:chat", + ":services:flipchat:payments", + ':services:flipchat:sdk', + + // common UI + ":ui:components", + ":ui:navigation", + ":ui:resources", + ":ui:theme", + + // 3rd party imported dependencies + ":vendor:tipkit:tipkit", + ":vendor:tipkit:tipkit-m2", +) diff --git a/ui/components/.gitignore b/ui/components/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/ui/components/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/ui/components/build.gradle.kts b/ui/components/build.gradle.kts new file mode 100644 index 000000000..710c950df --- /dev/null +++ b/ui/components/build.gradle.kts @@ -0,0 +1,83 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) +} + +android { + namespace = "com.getcode.ui.components" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { + jvmToolchain(17) + } + + buildFeatures { + buildConfig = true + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.compose_compiler + } +} + +dependencies { + implementation(project(":libs:datetime")) + implementation(project(":libs:encryption:ed25519")) + implementation(project(":libs:encryption:utils")) + implementation(project(":libs:currency")) + implementation(project(":libs:messaging")) + implementation(project(":libs:models")) + implementation(project(":libs:network:exchange")) + implementation(project(":libs:network:connectivity")) + implementation(project(":libs:opengraph")) + implementation(project(":libs:requests")) + implementation(project(":libs:vibrator")) + implementation(project(":ui:theme")) + implementation(project(":ui:resources")) + + api(Libs.cloudy) + + implementation(Libs.coil3) + implementation(Libs.coil3_network) + + implementation(Libs.kotlinx_datetime) + + implementation(platform(Libs.compose_bom)) + implementation(Libs.compose_animation) + implementation(Libs.compose_foundation) + implementation(Libs.compose_ui) + implementation(Libs.compose_activities) + debugApi(Libs.compose_ui_tools) + api(Libs.compose_ui_tools_preview) + implementation(Libs.compose_material) + implementation(Libs.compose_materialIconsExtended) + implementation(Libs.compose_accompanist) + implementation(Libs.compose_paging) + implementation(Libs.timber) +} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/Locals.kt b/ui/components/src/main/kotlin/com/getcode/ui/Locals.kt new file mode 100644 index 000000000..1fa932bf2 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/Locals.kt @@ -0,0 +1,7 @@ +package com.getcode.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf + +val LocalTopBarPadding: ProvidableCompositionLocal = staticCompositionLocalOf { PaddingValues() } diff --git a/app/src/main/java/com/getcode/ui/components/Badge.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/Badge.kt similarity index 56% rename from app/src/main/java/com/getcode/ui/components/Badge.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/Badge.kt index 6b36a7695..878998cdb 100644 --- a/app/src/main/java/com/getcode/ui/components/Badge.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/Badge.kt @@ -8,12 +8,14 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.getcode.theme.CodeTheme @@ -23,29 +25,37 @@ import com.getcode.theme.CodeTheme fun Badge( modifier: Modifier = Modifier, count: Int, + showMoreUnread: Boolean = count > 99, color: Color = CodeTheme.colors.brand, contentColor: Color = Color.White, + textStyle: TextStyle = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.W700), enterTransition: EnterTransition = scaleIn(tween(durationMillis = 300)) + fadeIn(), exitTransition: ExitTransition = fadeOut() + scaleOut(tween(durationMillis = 300)) ) { - AnimatedVisibility(modifier = modifier, visible = count > 0, enter = enterTransition, exit = exitTransition) { - val text = when (count) { - 0 -> "" - in 1..99 -> "$count" - else -> "99+" + AnimatedVisibility( + modifier = modifier, + visible = count > 0, + enter = enterTransition, + exit = exitTransition + ) { + val text = when { + count == 0 -> "" + showMoreUnread -> "$count+" + else -> "$count" } - Text( - text = text, - color = contentColor, - style = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.W700), - modifier = Modifier - .drawBehind { - drawCircle( - color = color, - radius = this.size.maxDimension / 2f - ) - }.padding(1.dp) - ) + Pill( + backgroundColor = color, + contentColor = contentColor, + contentPadding = PaddingValues( + horizontal = CodeTheme.dimens.grid.x1, + vertical = 0.dp + ) + ) { + Text( + text = text, + style = textStyle, + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/Cloudy.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/Cloudy.kt similarity index 100% rename from app/src/main/java/com/getcode/ui/components/Cloudy.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/Cloudy.kt diff --git a/app/src/main/java/com/getcode/view/main/connectivity/ConnectionStatusComponent.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/ConnectionStatusComponent.kt similarity index 91% rename from app/src/main/java/com/getcode/view/main/connectivity/ConnectionStatusComponent.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/ConnectionStatusComponent.kt index 6a7937339..3ad26108f 100644 --- a/app/src/main/java/com/getcode/view/main/connectivity/ConnectionStatusComponent.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/ConnectionStatusComponent.kt @@ -1,4 +1,4 @@ -package com.getcode.view.main.connectivity +package com.getcode.ui.components import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height @@ -10,9 +10,10 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import com.getcode.theme.CodeTheme +import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.utils.network.ConnectionType import com.getcode.utils.network.NetworkState -import com.getcode.ui.components.CodeCircularProgressIndicator +import com.getcode.utils.network.connectivity.NetworkStateProvider @Composable fun ConnectionStatus(state: NetworkState) { diff --git a/common/components/src/main/kotlin/com/getcode/ui/components/Flippable.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/Flippable.kt similarity index 100% rename from common/components/src/main/kotlin/com/getcode/ui/components/Flippable.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/Flippable.kt diff --git a/app/src/main/java/com/getcode/ui/components/Modal.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/Modal.kt similarity index 96% rename from app/src/main/java/com/getcode/ui/components/Modal.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/Modal.kt index f62383fd9..09481158e 100644 --- a/app/src/main/java/com/getcode/ui/components/Modal.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/Modal.kt @@ -21,7 +21,7 @@ import com.getcode.theme.CodeTheme @Composable fun Modal( modifier: Modifier = Modifier, - backgroundColor: Color = Brand, + backgroundColor: Color = CodeTheme.colors.brandContainer, content: @Composable ColumnScope.() -> Unit ) { Surface( diff --git a/app/src/main/java/com/getcode/ui/components/OnLifecycleEvent.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/OnLifecycleEvent.kt similarity index 100% rename from app/src/main/java/com/getcode/ui/components/OnLifecycleEvent.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/OnLifecycleEvent.kt diff --git a/common/components/src/main/kotlin/com/getcode/ui/components/Pill.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/Pill.kt similarity index 98% rename from common/components/src/main/kotlin/com/getcode/ui/components/Pill.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/Pill.kt index e9312cf58..887419af3 100644 --- a/common/components/src/main/kotlin/com/getcode/ui/components/Pill.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/Pill.kt @@ -41,6 +41,7 @@ fun Pill( Text( text = text, style = textStyle, + color = contentColor ) } ) diff --git a/app/src/main/java/com/getcode/view/main/scanner/components/PriceWithFlag.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/PriceWithFlag.kt similarity index 92% rename from app/src/main/java/com/getcode/view/main/scanner/components/PriceWithFlag.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/PriceWithFlag.kt index 75bedd913..b7d16076e 100644 --- a/app/src/main/java/com/getcode/view/main/scanner/components/PriceWithFlag.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/PriceWithFlag.kt @@ -1,4 +1,4 @@ -package com.getcode.view.main.scanner.components +package com.getcode.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -18,8 +18,8 @@ import androidx.compose.ui.unit.Dp import com.getcode.model.CurrencyCode import com.getcode.model.KinAmount import com.getcode.theme.CodeTheme -import com.getcode.util.flagResId -import com.getcode.util.formatted +import com.getcode.utils.flagResId +import com.getcode.extensions.formatted object PriceWithFlagDefaults { @@ -33,7 +33,7 @@ object PriceWithFlagDefaults { } } @Composable -internal fun PriceWithFlag( +fun PriceWithFlag( modifier: Modifier = Modifier, currencyCode: CurrencyCode, amount: KinAmount, diff --git a/app/src/main/java/com/getcode/ui/components/SelectionContainer.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/SelectionContainer.kt similarity index 100% rename from app/src/main/java/com/getcode/ui/components/SelectionContainer.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/SelectionContainer.kt diff --git a/app/src/main/java/com/getcode/ui/components/SettingsRow.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/SettingsSwitchRow.kt similarity index 58% rename from app/src/main/java/com/getcode/ui/components/SettingsRow.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/SettingsSwitchRow.kt index 657961561..f1edfe6d5 100644 --- a/app/src/main/java/com/getcode/ui/components/SettingsRow.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/SettingsSwitchRow.kt @@ -3,30 +3,42 @@ package com.getcode.ui.components import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.Image 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.width +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material.ContentAlpha +import androidx.compose.material.Divider import androidx.compose.material.LocalContentColor import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import com.getcode.theme.CodeTheme +import com.getcode.ui.theme.CodeToggleSwitch import com.getcode.ui.utils.rememberedClickable @Composable -fun SettingsRow( +fun SettingsSwitchRow( modifier: Modifier = Modifier, enabled: Boolean = true, title: String, icon: Int? = null, subtitle: String? = null, checked: Boolean, - onClick: () -> Unit) { + onClick: () -> Unit +) { Row( modifier = modifier .rememberedClickable(enabled) { onClick() } @@ -79,4 +91,51 @@ fun SettingsRow( onCheckedChange = null, ) } +} + +@Composable +fun SettingsRow( + modifier: Modifier = Modifier, + title: String, + icon: Painter?, + onClick: () -> Unit +) { + Column(modifier = Modifier.rememberedClickable { onClick() }.then(modifier)) { + Row( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(vertical = CodeTheme.dimens.grid.x5) + .fillMaxWidth() + .wrapContentHeight(), + verticalAlignment = CenterVertically + ) { + val imageMod = Modifier + .padding(end = CodeTheme.dimens.inset) + .height(CodeTheme.dimens.staticGrid.x5) + .width(CodeTheme.dimens.staticGrid.x5) + + if (icon != null) { + Image( + modifier = imageMod, + painter = icon, + colorFilter = ColorFilter.tint(Color.White), + contentDescription = "" + ) + } else { + Spacer(modifier = imageMod) + } + Text( + modifier = Modifier.align(CenterVertically), + text = title, + style = CodeTheme.typography.textLarge.copy( + fontWeight = FontWeight.Bold + ), + ) + } + + Divider( + color = CodeTheme.colors.divider, + thickness = 0.5.dp + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/SlideToConfirm.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/SlideToConfirm.kt similarity index 96% rename from app/src/main/java/com/getcode/ui/components/SlideToConfirm.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/SlideToConfirm.kt index 8fafadcf3..539242bb5 100644 --- a/app/src/main/java/com/getcode/ui/components/SlideToConfirm.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/SlideToConfirm.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.lerp import androidx.compose.ui.hapticfeedback.HapticFeedbackType @@ -67,9 +68,10 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times -import com.getcode.R import com.getcode.theme.CodeTheme +import com.getcode.theme.DesignSystem import com.getcode.theme.White50 +import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.utils.addIf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay @@ -100,8 +102,9 @@ object SlideToConfirmDefaults { } - val SnapThreshold = 0.7f - val BlueTrackColor = Track.BlueColor + const val SnapThreshold = 0.7f + val ThemedColor: Color + @Composable get() = Track.ThemedColor val BlackTrackColor = Track.BlackColor } @@ -118,8 +121,9 @@ private object Track { val Shape: Shape @Composable get() = CodeTheme.shapes.small - val BlueColor = Color(0xFF11142A) val BlackColor = Color(0xFF201D1D) + val ThemedColor: Color + @Composable get() = CodeTheme.colors.trackColor } @@ -157,7 +161,7 @@ fun SlideToConfirm( onConfirm: () -> Unit, modifier: Modifier = Modifier, trackShape: Shape = Track.Shape, - trackColor: Color = Track.BlueColor, + trackColor: Color = Track.ThemedColor, thumbShape: Shape = Thumb.Shape, isLoading: Boolean = false, isSuccess: Boolean = false, @@ -221,6 +225,7 @@ fun SlideToConfirm( Image( painter = painterResource(id = R.drawable.ic_check), contentDescription = "", + colorFilter = ColorFilter.tint(CodeTheme.colors.success), modifier = Modifier .size(CodeTheme.dimens.grid.x4) .align(Alignment.Center), @@ -371,7 +376,7 @@ private fun calculateHintTextColor(swipeFraction: Float): Color { private fun Preview() { var isLoading by remember { mutableStateOf(false) } var isSuccess by remember { mutableStateOf(false) } - CodeTheme { + DesignSystem { Column( verticalArrangement = Arrangement.Bottom, modifier = Modifier diff --git a/app/src/main/java/com/getcode/ui/components/TextInput.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/TextInput.kt similarity index 76% rename from app/src/main/java/com/getcode/ui/components/TextInput.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/TextInput.kt index 0d47b86ca..2e1cf75e1 100644 --- a/app/src/main/java/com/getcode/ui/components/TextInput.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/TextInput.kt @@ -8,19 +8,17 @@ import androidx.compose.foundation.border 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text2.BasicTextField2 -import androidx.compose.foundation.text2.input.TextFieldLineLimits -import androidx.compose.foundation.text2.input.TextFieldState -import androidx.compose.foundation.text2.input.textAsFlow +import androidx.compose.foundation.text.input.KeyboardActionHandler +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material.Text import androidx.compose.material.TextFieldColors import androidx.compose.runtime.Composable @@ -32,6 +30,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.neverEqualPolicy import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed @@ -54,23 +53,18 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat -import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme import com.getcode.theme.extraSmall import com.getcode.theme.inputColors import com.getcode.ui.utils.AutoSizeTextMeasurer +import com.getcode.ui.utils.ConstraintMode import com.getcode.ui.utils.addIf +import com.getcode.ui.utils.constrain import com.getcode.ui.utils.measured import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlin.math.roundToInt -sealed interface ConstraintMode { - data object Free : ConstraintMode - data class AutoSize(val minimum: TextStyle) : ConstraintMode -} - -@OptIn(ExperimentalFoundationApi::class) @Composable fun TextInput( modifier: Modifier = Modifier, @@ -81,8 +75,8 @@ fun TextInput( minHeight: Dp = 56.dp, contentPadding: PaddingValues = PaddingValues(), onStateChanged: () -> Unit = { }, - keyboardActions: KeyboardActions = KeyboardActions(), keyboardOptions: KeyboardOptions = KeyboardOptions(), + onKeyboardAction: KeyboardActionHandler? = null, style: TextStyle = CodeTheme.typography.textMedium, placeholderStyle: TextStyle = CodeTheme.typography.textMedium, shape: Shape = CodeTheme.shapes.extraSmall, @@ -109,7 +103,7 @@ fun TextInput( var textFieldSize by remember { mutableStateOf(DpSize.Zero) } Box(modifier = modifier.measured { textFieldSize = it }) { - BasicTextField2( + BasicTextField( modifier = Modifier .background(backgroundColor, shape) .defaultMinSize(minHeight = minHeight) @@ -129,7 +123,7 @@ fun TextInput( state = state, cursorBrush = SolidColor(colors.cursorColor(isError = false).value), keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, + onKeyboardAction = onKeyboardAction, textStyle = style.copy(color = textColor, fontSize = textSize), lineLimits = if (maxLines == 1) { TextFieldLineLimits.SingleLine @@ -158,7 +152,7 @@ fun TextInput( } LaunchedEffect(Unit) { - state.textAsFlow() + snapshotFlow { state.text } .onEach { onStateChanged() } .launchIn(this) } @@ -172,54 +166,13 @@ fun TextInput( } } -@OptIn(ExperimentalFoundationApi::class) -private fun Modifier.constrain( - mode: ConstraintMode, - state: TextFieldState, - style: TextStyle, - frameConstraints: Constraints, - onTextSizeDetermined: (TextUnit) -> Unit -): Modifier = this.composed { - val textMeasurer = rememberTextMeasurer() - val autosizeTextMeasurer = remember(textMeasurer) { AutoSizeTextMeasurer(textMeasurer) } - val textLayoutResult = remember { Ref() } - var flag by remember { mutableStateOf(Unit, neverEqualPolicy()) } - - Modifier.addIf(mode is ConstraintMode.AutoSize) { - Modifier.layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - val result = autosizeTextMeasurer.measure( - text = AnnotatedString(state.text.toString()), - style = style, - constraints = Constraints( - maxWidth = (frameConstraints.maxWidth * 0.85f).roundToInt(), - minHeight = 0 - ), - minFontSize = (mode as ConstraintMode.AutoSize).minimum.fontSize, - maxFontSize = style.fontSize, - autosizeGranularity = 100 - ) - - textLayoutResult.value = result - flag = Unit - - onTextSizeDetermined(result.layoutInput.style.fontSize) - - layout(placeable.width, placeable.height) { - placeable.placeRelative(0, 0) - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) @Composable private fun DecoratorBox( state: TextFieldState, placeholder: String, placeholderStyle: TextStyle, placeholderColor: Color, - borderColor: Color = BrandLight, + borderColor: Color = CodeTheme.colors.brandLight, contentPadding: PaddingValues, leadingIcon: (@Composable () -> Unit)?, trailingIcon: (@Composable () -> Unit)?, @@ -241,7 +194,9 @@ private fun DecoratorBox( Box( modifier = Modifier .weight(1f) - .padding(horizontal = CodeTheme.dimens.staticGrid.x2), + .addIf(leadingIcon != null) { + Modifier.padding(start = CodeTheme.dimens.staticGrid.x2) + }, contentAlignment = Alignment.CenterStart ) { Box(modifier = Modifier.padding(contentPadding)) { @@ -249,7 +204,7 @@ private fun DecoratorBox( } if (state.text.isEmpty() && placeholder.isNotEmpty()) { Text( - modifier = Modifier.then(Modifier.padding(contentPadding)), + modifier = Modifier.fillMaxWidth().then(Modifier.padding(contentPadding)), text = placeholder, style = placeholderStyle.copy(color = placeholderColor), maxLines = 1, diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt new file mode 100644 index 000000000..448ff0a79 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt @@ -0,0 +1,275 @@ +package com.getcode.ui.components + +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsIgnoringVisibility +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.Logout +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme +import com.getcode.theme.DesignSystem +import com.getcode.ui.utils.addIf +import com.getcode.ui.utils.calculateHorizontalPadding +import com.getcode.ui.utils.unboundedClickable + +object AppBarDefaults { + @Composable + fun UpNavigation(modifier: Modifier = Modifier, onClick: () -> Unit) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "", + tint = Color.White, + modifier = modifier + .wrapContentWidth() + .size(24.dp) + .unboundedClickable { onClick() } + ) + } + + @Composable + fun Close(modifier: Modifier = Modifier, onClick: () -> Unit) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = "", + tint = Color.White, + modifier = modifier + .wrapContentWidth() + .size(24.dp) + .unboundedClickable { onClick() } + ) + } + + @Composable + fun Share(modifier: Modifier = Modifier, onClick: () -> Unit) { + Icon( + painter = painterResource(R.drawable.ic_remote_send), + contentDescription = "", + tint = Color.White, + modifier = modifier + .wrapContentWidth() + .size(24.dp) + .unboundedClickable { onClick() } + ) + } + + @Composable + fun Leave(modifier: Modifier = Modifier, onClick: () -> Unit) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.Logout, + contentDescription = "", + tint = Color.White, + modifier = modifier + .wrapContentWidth() + .size(24.dp) + .unboundedClickable { onClick() } + ) + } + + @Composable + fun Settings(modifier: Modifier = Modifier, onClick: () -> Unit) { + Icon( + painter = painterResource(R.drawable.ic_settings_outline), + contentDescription = "", + tint = Color.White, + modifier = modifier + .wrapContentWidth() + .size(24.dp) + .unboundedClickable { onClick() } + ) + } + + @Composable + fun Overflow( + modifier: Modifier = Modifier, + onClick: () -> Unit + ) { + Icon( + imageVector = Icons.Outlined.MoreVert, + contentDescription = "", + tint = Color.White, + modifier = modifier + .wrapContentWidth() + .size(24.dp) + .unboundedClickable { onClick() } + ) + } + + @Composable + fun Title( + modifier: Modifier = Modifier, + text: String = "", + style: TextStyle = CodeTheme.typography.screenTitle, + ) { + Text( + modifier = modifier, + text = text, + style = style, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AppBarWithTitle( + modifier: Modifier = Modifier, + isInModal: Boolean = false, + title: String = "", + titleAlignment: Alignment.Horizontal = Alignment.Start, + backButton: Boolean = false, + onBackIconClicked: () -> Unit = {}, + endContent: @Composable () -> Unit = { }, +) { + TopAppBarBase( + modifier = modifier + .addIf(!isInModal) { Modifier.windowInsetsPadding(WindowInsets.statusBarsIgnoringVisibility) }, + leftIcon = { + if (backButton) { + AppBarDefaults.UpNavigation { onBackIconClicked() } + } + }, + titleRegion = { + AppBarDefaults.Title(text = title) + }, + titleAlignment = titleAlignment, + rightContents = endContent + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AppBarWithTitle( + modifier: Modifier = Modifier, + title: String = "", + titleAlignment: Alignment.Horizontal = Alignment.Start, + startContent: @Composable () -> Unit = { }, + endContent: @Composable () -> Unit = { }, +) { + TopAppBarBase( + modifier = modifier.windowInsetsPadding(WindowInsets.statusBarsIgnoringVisibility), + leftIcon = startContent, + titleRegion = { + AppBarDefaults.Title(text = title) + }, + titleAlignment = titleAlignment, + rightContents = endContent + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AppBarWithTitle( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + titleAlignment: Alignment.Horizontal = Alignment.Start, + contentPadding: PaddingValues = PaddingValues(horizontal = CodeTheme.dimens.grid.x2), + leftIcon: @Composable () -> Unit = { }, + rightContents: @Composable () -> Unit = { } +) { + TopAppBarBase( + modifier = modifier.windowInsetsPadding(WindowInsets.statusBarsIgnoringVisibility), + leftIcon = leftIcon, + rightContents = rightContents, + contentPadding = contentPadding, + titleRegion = title, + titleAlignment = titleAlignment + ) +} + +@Composable +private fun TopAppBarBase( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(horizontal = CodeTheme.dimens.grid.x2), + leftIcon: @Composable () -> Unit = { }, + titleRegion: @Composable () -> Unit = { }, + rightContents: @Composable () -> Unit = { }, + titleAlignment: Alignment.Horizontal = Alignment.CenterHorizontally // New parameter +) { + val inset = CodeTheme.dimens.inset + val horizontal = contentPadding.calculateHorizontalPadding() + + SubcomposeLayout(modifier = modifier.height(56.dp)) { constraints -> + // Measure left icon, if provided + val leftIconPlaceable = subcompose("leftIcon", leftIcon).firstOrNull()?.measure( + constraints.copy(minWidth = 0, minHeight = 0) + ) + + // Measure right contents, if provided + val rightContentsPlaceable = subcompose("rightContents", rightContents).firstOrNull()?.measure( + constraints.copy(minWidth = 0, minHeight = 0) + ) + + // Calculate the remaining space for the title region + val leftIconWidth = leftIconPlaceable?.width ?: 0 + val rightContentsWidth = rightContentsPlaceable?.width ?: 0 + val remainingWidth = constraints.maxWidth - leftIconWidth - rightContentsWidth - contentPadding.calculateLeftPadding(layoutDirection).roundToPx() - (contentPadding.calculateRightPadding(layoutDirection).roundToPx() * 2) + + // Measure title region with the remaining space, if provided + val titleRegionPlaceable = subcompose("titleRegion", titleRegion).firstOrNull()?.measure( + constraints.copy(minWidth = 0, minHeight = 0, maxWidth = remainingWidth) + ) + + layout(constraints.maxWidth, constraints.maxHeight) { + // Place left icon, if present + leftIconPlaceable?.placeRelative( + x = contentPadding.calculateLeftPadding(layoutDirection).roundToPx(), + y = (constraints.maxHeight - (leftIconPlaceable.height)) / 2 + ) + + // Place right contents, if present + rightContentsPlaceable?.placeRelative( + x = constraints.maxWidth - rightContentsWidth - contentPadding.calculateRightPadding(layoutDirection).roundToPx(), + y = (constraints.maxHeight - rightContentsPlaceable.height) / 2 + ) + + // Place title region with configurable alignment + val titleX = when (titleAlignment) { + Alignment.Start -> { + if (leftIconWidth == 0) inset.roundToPx() + else leftIconWidth + horizontal.roundToPx() + } + Alignment.End -> constraints.maxWidth - rightContentsWidth - contentPadding.calculateRightPadding(layoutDirection).roundToPx() - (titleRegionPlaceable?.width ?: 0) + else -> (constraints.maxWidth - (titleRegionPlaceable?.width ?: 0)) / 2 + } + + // Place title region + titleRegionPlaceable?.placeRelative( + x = titleX, + y = (constraints.maxHeight - (titleRegionPlaceable.height)) / 2 + ) + } + } +} + +@Preview +@Composable +fun Preview_TitleBar( + +) { + DesignSystem { + AppBarWithTitle(backButton = true, title = "Hey") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/TwitterUsernameDisplay.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/TwitterUsernameDisplay.kt similarity index 98% rename from app/src/main/java/com/getcode/ui/components/TwitterUsernameDisplay.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/TwitterUsernameDisplay.kt index bda8319a8..9cacaf2ab 100644 --- a/app/src/main/java/com/getcode/ui/components/TwitterUsernameDisplay.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/TwitterUsernameDisplay.kt @@ -10,7 +10,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.vectorResource -import com.getcode.R import com.getcode.model.TwitterUser import com.getcode.theme.CodeTheme diff --git a/app/src/main/java/com/getcode/ui/components/VerticalDivider.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/VerticalDivider.kt similarity index 92% rename from app/src/main/java/com/getcode/ui/components/VerticalDivider.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/VerticalDivider.kt index 0b9d04c71..1969337d4 100644 --- a/app/src/main/java/com/getcode/ui/components/VerticalDivider.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/VerticalDivider.kt @@ -8,14 +8,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp -import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme @Composable fun VerticalDivider( modifier: Modifier = Modifier, thickness: Dp = CodeTheme.dimens.border, - color: Color = BrandLight + color: Color = CodeTheme.colors.brandLight, ) = Canvas(modifier.fillMaxHeight().width(thickness)) { drawLine( color = color, diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BarManager.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BarManager.kt new file mode 100644 index 000000000..e797684ba --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BarManager.kt @@ -0,0 +1,37 @@ +package com.getcode.ui.components.bars + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import com.getcode.manager.BottomBarManager +import com.getcode.manager.TopBarManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + + +@Composable +fun rememberBarManager( + coroutineScope: CoroutineScope = rememberCoroutineScope() +) = + remember(coroutineScope) { + BarManager(coroutineScope) + } + +class BarManager( + coroutineScope: CoroutineScope +) { + val barMessages = BarMessages() + + init { + coroutineScope.launch { + TopBarManager.messages.collect { currentMessages -> + barMessages.topBar.value = currentMessages.firstOrNull() + } + } + coroutineScope.launch { + BottomBarManager.messages.collect { currentMessages -> + barMessages.bottomBar.value = currentMessages.firstOrNull() + } + } + } +} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BarMessages.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BarMessages.kt new file mode 100644 index 000000000..2dbb73af2 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BarMessages.kt @@ -0,0 +1,10 @@ +package com.getcode.ui.components.bars + +import com.getcode.manager.BottomBarManager +import com.getcode.manager.TopBarManager +import kotlinx.coroutines.flow.MutableStateFlow + +class BarMessages { + val topBar = MutableStateFlow(null) + val bottomBar = MutableStateFlow(null) +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/bars/BottomBarContainer.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt similarity index 53% rename from app/src/main/java/com/getcode/ui/components/bars/BottomBarContainer.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt index 5e9d7060f..b82d7ca89 100644 --- a/app/src/main/java/com/getcode/ui/components/bars/BottomBarContainer.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt @@ -3,6 +3,7 @@ package com.getcode.ui.components.bars import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically @@ -11,9 +12,11 @@ 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.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding @@ -30,29 +33,32 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import com.getcode.CodeAppState +import androidx.compose.ui.util.fastForEach import com.getcode.manager.BottomBarManager -import com.getcode.theme.BrandLight +import com.getcode.theme.Black40 +import com.getcode.theme.Brand import com.getcode.theme.CodeTheme import com.getcode.theme.White -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton import com.getcode.ui.utils.rememberedClickable -import com.getcode.utils.trace +import com.getcode.util.resources.R import kotlinx.coroutines.delay import kotlinx.coroutines.launch @Composable -fun BottomBarContainer(appState: CodeAppState) { +fun BottomBarContainer(barMessages: BarMessages) { val scope = rememberCoroutineScope() - val bottomBarMessage by appState.bottomBarMessage.collectAsState() + val bottomBarMessage by barMessages.bottomBar.collectAsState() val bottomBarVisibleState = remember(bottomBarMessage?.id) { MutableTransitionState(false) } var bottomBarMessageDismissId by remember { mutableLongStateOf(0L) } - val onClose: suspend (bottomBarActionType: BottomBarManager.BottomBarActionType?) -> Unit = { + val onClose: suspend (fromAction: Boolean) -> Unit = { fromAction -> bottomBarMessageDismissId = bottomBarMessage?.id ?: 0 bottomBarVisibleState.targetState = false - bottomBarMessage?.onClose?.invoke(it) + bottomBarMessage?.onClose?.invoke(fromAction) delay(100) BottomBarManager.setMessageShown(bottomBarMessageDismissId) @@ -74,26 +80,42 @@ fun BottomBarContainer(appState: CodeAppState) { LaunchedEffect(bottomBarMessage) { bottomBarMessage?.timeoutSeconds?.let { delay(it * 1000L) - onClose(null) + onClose(false) } } - // add transparent touch handler if dismissible + val scrimAlpha by animateFloatAsState(if (bottomBarMessage?.showScrim == true) 1f else 0f, label = "scrim visibility") + if (bottomBarVisibleState.targetState && bottomBarMessage != null) { bottomBarMessage?.let { - if (it.isDismissible) { + if (it.showScrim) { Box( modifier = Modifier .fillMaxSize() + .alpha(scrimAlpha) + .background(Black40) .rememberedClickable(indication = null, interactionSource = remember { MutableInteractionSource() } ) { - scope.launch { onClose(null) } + if (it.isDismissible) { + scope.launch { onClose(false) } + } } ) + } else { + if (it.isDismissible) { + Box( + modifier = Modifier + .fillMaxSize() + .rememberedClickable(indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + scope.launch { onClose(false) } + } + ) + } } } - } AnimatedVisibility( @@ -108,24 +130,21 @@ fun BottomBarContainer(appState: CodeAppState) { animationSpec = tween(300) ), ) { - val closeWith: (BottomBarManager.BottomBarActionType?) -> Unit = { type -> - scope.launch { onClose(type) } + val closeWith: (fromAction: Boolean) -> Unit = { fromAction -> + scope.launch { onClose(fromAction) } } - BottomBarView(bottomBarMessage = bottomBarMessage, closeWith, onBackPressed = { closeWith(null)}) + BottomBarView(bottomBarMessage = bottomBarMessage, closeWith, onBackPressed = { closeWith(false)}) } } @Composable fun BottomBarView( bottomBarMessage: BottomBarManager.BottomBarMessage?, - onClose: (bottomBarActionType: BottomBarManager.BottomBarActionType?) -> Unit, + onClose: (fromAction: Boolean) -> Unit, onBackPressed: () -> Unit ) { bottomBarMessage ?: return - LaunchedEffect(bottomBarMessage) { - trace("bottom bar message shown=${bottomBarMessage.title}") - } BackHandler(enabled = bottomBarMessage.isDismissible) { onBackPressed() } @@ -137,66 +156,71 @@ fun BottomBarView( modifier = Modifier .background( when (bottomBarMessage.type) { - BottomBarManager.BottomBarMessageType.DEFAULT -> CodeTheme.colors.error - BottomBarManager.BottomBarMessageType.REMOTE_SEND -> BrandLight + BottomBarManager.BottomBarMessageType.DESTRUCTIVE -> CodeTheme.colors.error + BottomBarManager.BottomBarMessageType.REMOTE_SEND -> CodeTheme.colors.brandLight + BottomBarManager.BottomBarMessageType.THEMED -> CodeTheme.colors.brand } ) - .padding(CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.inset) + .padding(horizontal = CodeTheme.dimens.inset) .windowInsetsPadding(WindowInsets.navigationBars), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3) ) { - CompositionLocalProvider(LocalContentColor provides White) { - Column(verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2)) { - Text( - style = CodeTheme.typography.textLarge, - text = bottomBarMessage.title - ) - Text( - style = CodeTheme.typography.textSmall, - text = bottomBarMessage.subtitle, - color = LocalContentColor.current.copy(alpha = 0.8f) - ) + if (bottomBarMessage.title.isNotEmpty()) { + CompositionLocalProvider(LocalContentColor provides White) { + Column(verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2)) { + Text( + style = CodeTheme.typography.textLarge, + text = bottomBarMessage.title + ) + Text( + style = CodeTheme.typography.textSmall, + text = bottomBarMessage.subtitle, + color = LocalContentColor.current.copy(alpha = 0.8f) + ) + } } } Column(verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2)) { - CodeButton( - modifier = Modifier.fillMaxWidth(), - onClick = { - bottomBarMessage.onPositive() - onClose(BottomBarManager.BottomBarActionType.Positive) - }, - textColor = - when (bottomBarMessage.type) { - BottomBarManager.BottomBarMessageType.DEFAULT -> CodeTheme.colors.error - BottomBarManager.BottomBarMessageType.REMOTE_SEND -> BrandLight - }, - buttonState = ButtonState.Filled, - text = bottomBarMessage.positiveText - ) - CodeButton( - modifier = Modifier.fillMaxWidth(), - onClick = { - bottomBarMessage.onNegative() - onClose(BottomBarManager.BottomBarActionType.Negative) - }, - textColor = White, - buttonState = ButtonState.Filled10, - text = bottomBarMessage.negativeText - ) - bottomBarMessage.tertiaryText?.let { + bottomBarMessage.actions.fastForEach { action -> + CodeButton( + modifier = Modifier.fillMaxWidth(), + onClick = { + action.onClick() + onClose(true) + }, + textColor = when (bottomBarMessage.type) { + BottomBarManager.BottomBarMessageType.DESTRUCTIVE -> CodeTheme.colors.error + BottomBarManager.BottomBarMessageType.REMOTE_SEND -> CodeTheme.colors.brandLight + BottomBarManager.BottomBarMessageType.THEMED -> Brand + }, + buttonState = when (action.style) { + BottomBarManager.BottomBarButtonStyle.Filled -> ButtonState.Filled + BottomBarManager.BottomBarButtonStyle.Filled10 -> ButtonState.Filled10 + }, + text = action.text + ) + } + if (bottomBarMessage.showCancel) { Text( modifier = Modifier .fillMaxWidth() .rememberedClickable { - bottomBarMessage.onTertiary() - onClose(BottomBarManager.BottomBarActionType.Tertiary) + onClose(false) } - .padding(vertical = CodeTheme.dimens.grid.x2), + .padding(vertical = CodeTheme.dimens.grid.x3), style = CodeTheme.typography.textMedium, textAlign = TextAlign.Center, - color = White, - text = it + color = when (bottomBarMessage.type) { + BottomBarManager.BottomBarMessageType.DESTRUCTIVE, + BottomBarManager.BottomBarMessageType.REMOTE_SEND -> White + + BottomBarManager.BottomBarMessageType.THEMED -> CodeTheme.colors.textSecondary + }, + text = stringResource(R.string.action_cancel) ) + } else { + Spacer(Modifier.height(CodeTheme.dimens.inset)) } } } diff --git a/app/src/main/java/com/getcode/ui/components/bars/TopBarContainer.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/bars/TopBarContainer.kt similarity index 95% rename from app/src/main/java/com/getcode/ui/components/bars/TopBarContainer.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/bars/TopBarContainer.kt index 251c06c49..b0613ec22 100644 --- a/app/src/main/java/com/getcode/ui/components/bars/TopBarContainer.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/bars/TopBarContainer.kt @@ -21,19 +21,20 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.getcode.CodeAppState -import com.getcode.R import com.getcode.manager.TopBarManager import com.getcode.manager.TopBarManager.TopBarMessageType.* import com.getcode.theme.* +import com.getcode.ui.components.R import com.getcode.ui.components.VerticalDivider import com.getcode.ui.utils.rememberedClickable import java.util.* import kotlin.concurrent.timerTask @Composable -fun TopBarContainer(appState: CodeAppState) { - val topBarMessage by appState.topBarMessage.collectAsState() +fun TopBarContainer( + barMessages: BarMessages, +) { + val topBarMessage by barMessages.topBar.collectAsState() val topBarVisibleState = remember { MutableTransitionState(false) } var topBarMessageDismissId by remember { mutableLongStateOf(0L) } @@ -94,7 +95,7 @@ private fun TopBarView( WARNING -> Warning NOTIFICATION -> TopNotification NEUTRAL -> TopNeutral - SUCCESS -> TopSuccess + SUCCESS -> CodeTheme.colors.brand } ) @@ -103,7 +104,7 @@ private fun TopBarView( .fillMaxWidth() ) { CompositionLocalProvider(LocalContentColor provides White) { - com.getcode.ui.components.Row( + Row( modifier = Modifier .padding(bottom = CodeTheme.dimens.grid.x2) .padding(horizontal = CodeTheme.dimens.inset) @@ -155,12 +156,14 @@ private fun TopBarView( .height(CodeTheme.dimens.border) .background(Black10) ) - com.getcode.ui.components.Row(modifier = Modifier + + val color = CodeTheme.colors.brandLight + Row(modifier = Modifier .height(IntrinsicSize.Min) .drawBehind { val strokeWidth = Dp.Hairline.toPx() drawLine( - color = BrandLight, + color = color, Offset(0f, 0f), Offset(size.width, 0f), strokeWidth diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/AnonymousAvatar.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/AnonymousAvatar.kt new file mode 100644 index 000000000..1a499ce63 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/AnonymousAvatar.kt @@ -0,0 +1,139 @@ +package com.getcode.ui.components.chat + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +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.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.getcode.theme.DesignSystem +import com.getcode.ui.components.R +import com.getcode.ui.utils.IDPreviewParameterProvider +import com.getcode.ui.utils.generateComplementaryColorPalette +import com.getcode.ui.utils.generateEightBitAvatar +import com.getcode.utils.bytes +import com.getcode.utils.decodeBase58 +import java.util.UUID + +enum class AnonymousRender { + EightBit, Gradient +} + +@Composable +fun AnonymousAvatar( + memberId: UUID, + modifier: Modifier = Modifier, + type: AnonymousRender = AnonymousRender.Gradient, + icon: @Composable BoxScope.() -> Unit = { } +) { + AnonymousAvatar(modifier = modifier, data = memberId.bytes, type = type, overlay = icon) +} + +@Composable +fun AnonymousAvatar( + data: List, + modifier: Modifier = Modifier, + type: AnonymousRender = AnonymousRender.EightBit, + overlay: @Composable BoxScope.() -> Unit = { } +) { + + Box( + modifier = modifier + .background(Color(0xFFE6F0FA), CircleShape) + .aspectRatio(1f) + .clip(CircleShape) + .fillMaxSize() + .drawWithCache { + when (type) { + AnonymousRender.EightBit -> { + val avatar = if (size.isEmpty().not()) { + generateEightBitAvatar(data, size) + } else { + null + } + + onDrawWithContent { + if (avatar != null) { + drawImage(avatar) + } else { + drawRect(Color.Transparent) + } + } + } + + AnonymousRender.Gradient -> { + val colors = generateComplementaryColorPalette(data) + val gradient = if (colors != null) { + Brush.linearGradient( + colorStops = arrayOf( + 0.14f to colors.first, + 0.38f to colors.second, + 0.67f to colors.third, + ), + start = Offset(Float.POSITIVE_INFINITY, 0f), + end = Offset(0f, Float.POSITIVE_INFINITY) + ) + } else { + null + } + + onDrawWithContent { + if (gradient != null) { + drawRect(brush = gradient) + } else { + drawRect(Color.Transparent) + } + drawContent() + } + } + } + }, + contentAlignment = Alignment.Center + ) { + overlay() + } +} + +@Preview +@Composable +fun Preview_Avatars() { + DesignSystem { + val provider = IDPreviewParameterProvider(40) + LazyVerticalGrid(columns = GridCells.Fixed(8)) { + + items(provider.values.toList()) { + Box(modifier = Modifier.padding(8.dp)) { + AnonymousAvatar( + modifier = Modifier.fillMaxSize(), + data = it, + type = AnonymousRender.Gradient + ) { + Image( + modifier = Modifier.padding(5.dp), + painter = painterResource(R.drawable.ic_chat), + colorFilter = ColorFilter.tint(Color.White), + contentDescription = null, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/getcode/ui/components/chat/ChatInput.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt similarity index 79% rename from app/src/main/java/com/getcode/ui/components/chat/ChatInput.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt index cd1023cb0..4ed9dc968 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/ChatInput.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt @@ -1,54 +1,55 @@ package com.getcode.ui.components.chat import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text2.input.TextFieldState -import androidx.compose.foundation.text2.input.rememberTextFieldState +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.Icon import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Send import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.getcode.LocalBetaFlags -import com.getcode.R import com.getcode.theme.CodeTheme +import com.getcode.theme.DesignSystem import com.getcode.theme.extraLarge import com.getcode.theme.inputColors +import com.getcode.ui.components.R import com.getcode.ui.components.TextInput import com.getcode.ui.utils.rememberedClickable -import com.getcode.ui.utils.withTopBorder -@OptIn(ExperimentalFoundationApi::class) @Composable fun ChatInput( modifier: Modifier = Modifier, + enabled: Boolean = true, + hint: String = "", state: TextFieldState = rememberTextFieldState(), + focusRequester: FocusRequester = remember { FocusRequester() }, sendCashEnabled: Boolean = false, onSendMessage: () -> Unit, onSendCash: () -> Unit, @@ -57,8 +58,11 @@ fun ChatInput( modifier = modifier .fillMaxWidth() .height(IntrinsicSize.Min) - .withTopBorder() - .padding(CodeTheme.dimens.grid.x2), + .padding( + start = CodeTheme.dimens.grid.x2, + top = CodeTheme.dimens.grid.x2, + bottom = CodeTheme.dimens.grid.x2, + ), horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), verticalAlignment = Alignment.Bottom ) { @@ -85,14 +89,22 @@ fun ChatInput( TextInput( modifier = Modifier - .weight(1f), + .weight(1f) + .focusRequester(focusRequester), minHeight = 40.dp, + enabled = enabled, state = state, + placeholder = hint, shape = CodeTheme.shapes.extraLarge, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences ), - contentPadding = PaddingValues(8.dp), + contentPadding = PaddingValues( + start = 8.dp + CodeTheme.dimens.staticGrid.x2, + top = 8.dp, + end = 8.dp + CodeTheme.dimens.staticGrid.x2, + bottom = 8.dp + ), colors = inputColors( backgroundColor = Color.White, textColor = CodeTheme.colors.background, @@ -100,7 +112,7 @@ fun ChatInput( ) ) AnimatedContent( - targetState = state.text.isNotEmpty(), + targetState = true, // TODO: state.text.isNotEmpty(), label = "show/hide send button", transitionSpec = { slideInHorizontally { it } togetherWith slideOutHorizontally { it } @@ -109,9 +121,10 @@ fun ChatInput( if (show) { Box( modifier = Modifier + .padding(end = CodeTheme.dimens.grid.x2) .size(ChatInput_Size) .align(Alignment.Bottom) - .background(CodeTheme.colors.secondary, shape = CircleShape) + .background(CodeTheme.colors.tertiary, shape = CircleShape) .clip(CircleShape) .rememberedClickable { onSendMessage() } .padding(8.dp), @@ -134,7 +147,7 @@ fun ChatInput( @Preview @Composable private fun Preview_ChatInput() { - CodeTheme { + DesignSystem { ChatInput( sendCashEnabled = true, onSendMessage = {}, diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatNode.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatNode.kt new file mode 100644 index 000000000..71f619d90 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatNode.kt @@ -0,0 +1,209 @@ +package com.getcode.ui.components.chat + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeOff +import androidx.compose.material.icons.outlined.BorderColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.Badge +import com.getcode.ui.components.R +import com.getcode.ui.utils.rememberedClickable +import com.getcode.util.DateUtils +import com.getcode.util.formatTimeRelatively + +@Composable +fun ChatNode( + title: String, + modifier: Modifier = Modifier, + avatar: Any? = null, + avatarIconWhenFallback: @Composable BoxScope.() -> Unit = { }, + messagePreview: Pair>, + titleTextStyle: TextStyle = CodeTheme.typography.textMedium, + messageTextStyle: TextStyle = CodeTheme.typography.textMedium, + messageMinLines: Int = 2, + timestamp: Long? = null, + isMuted: Boolean = false, + isHost: Boolean = false, + unreadCount: Int = 0, + showMoreUnread: Boolean = unreadCount > 99, + onClick: () -> Unit, +) { + Row( + modifier = modifier + .rememberedClickable { onClick() } + .padding( + vertical = CodeTheme.dimens.grid.x3, + horizontal = CodeTheme.dimens.inset + ), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically + ) { + avatar?.let { + HostableAvatar( + size = CodeTheme.dimens.staticGrid.x12, + imageData = it, + overlay = avatarIconWhenFallback, + isHost = isHost + ) + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy( + CodeTheme.dimens.grid.x1, + Alignment.CenterVertically + ), + ) { + val hasUnreadMessages = remember(unreadCount) { unreadCount > 0 } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2) + ) { + Text( + modifier = Modifier.weight(1f), + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = titleTextStyle + ) + timestamp?.let { + val isToday = DateUtils.isToday(it) + Text( + text = if (isToday) { + it.formatTimeRelatively() + } else { + DateUtils.getDateRelatively(it) + }, + style = CodeTheme.typography.textSmall, + color = when { + hasUnreadMessages -> CodeTheme.colors.indicator + else -> CodeTheme.colors.textSecondary + }, + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset), + verticalAlignment = Alignment.Top + ) { + + val (preview, inlineContent) = messagePreview + + Text( + modifier = Modifier.weight(1f), + text = preview, + inlineContent = inlineContent, + style = messageTextStyle, + color = CodeTheme.colors.textSecondary, + minLines = messageMinLines.coerceAtMost(2), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Row( + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.CenterVertically + ) { + if (isMuted) { + Icon( + modifier = Modifier + .size(CodeTheme.dimens.staticGrid.x4), + imageVector = Icons.AutoMirrored.Filled.VolumeOff, + contentDescription = "chat is muted", + tint = CodeTheme.colors.textSecondary + ) + } + + Badge( + count = unreadCount, + showMoreUnread = showMoreUnread, + color = CodeTheme.colors.indicator, + contentColor = Color.White + ) + } + + } + } + } +} + +//private val Chat.messagePreview: Pair> +// @Composable get() { +// val contents = newestMessage?.contents ?: return AnnotatedString("No content") to emptyMap() +// +// var filtered: List = contents.filterIsInstance() +// if (filtered.isEmpty()) { +// filtered = contents +// } +// +// val selfMember = self +// val pointer = +// selfMember?.pointers.orEmpty().find { it.messageId == newestMessage?.id?.uuid } +// +// // joinToString does expose a Composable scoped lambda +// @Suppress("SimplifiableCallChain") +// val messageBody = filtered.map { it.localizedText }.joinToString(" ") +// +// val textStyle = CodeTheme.typography.textMedium +// return if (pointer != null && pointer !is Pointer.Unknown && isConversation) { +// val string = buildAnnotatedString { +// appendInlineContent("status", "status") +// append(" ") +// append(messageBody) +// } +// +// string to mapOf( +// "status" to InlineTextContent( +// Placeholder( +// textStyle.fontSize, +// textStyle.fontSize, +// PlaceholderVerticalAlign.TextCenter +// ) +// ) { +// Image( +// painter = painterResource( +// id = when (pointer) { +// is Pointer.Delivered -> R.drawable.ic_message_status_delivered +// is Pointer.Read -> R.drawable.ic_message_status_read +// is Pointer.Sent -> R.drawable.ic_message_status_sent +// else -> -1 +// } +// ), +// modifier = Modifier.fillMaxSize(), +// contentDescription = "" +// ) +// } +// ) +// } else { +// AnnotatedString(messageBody) to emptyMap() +// } +// } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/chat/DateBubble.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/DateBubble.kt similarity index 85% rename from app/src/main/java/com/getcode/ui/components/chat/DateBubble.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/chat/DateBubble.kt index 88a97ef55..e372914b2 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/DateBubble.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/DateBubble.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.getcode.theme.BrandDark +import com.getcode.theme.CodeTheme import com.getcode.ui.components.Pill @Composable @@ -15,6 +16,6 @@ internal fun DateBubble( ) = Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Pill( text = date, - backgroundColor = BrandDark + backgroundColor = CodeTheme.colors.surfaceVariant ) } \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/HostableAvatar.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/HostableAvatar.kt new file mode 100644 index 000000000..fe6bdc78b --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/HostableAvatar.kt @@ -0,0 +1,172 @@ +package com.getcode.ui.components.chat + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.R +import com.getcode.ui.utils.heightOrZero +import com.getcode.ui.utils.widthOrZero +import kotlin.math.cos +import kotlin.math.roundToInt +import kotlin.math.sin + +sealed interface AvatarEndAction { + val backgroundColor: Color + val contentColor: Color + + data class Icon( + val icon: Painter, + override val backgroundColor: Color, + override val contentColor: Color + ) : AvatarEndAction +} + +@Composable +fun HostableAvatar( + imageData: Any?, + modifier: Modifier = Modifier, + imageModifier: Modifier = Modifier, + size: Dp = CodeTheme.dimens.staticGrid.x8, + endAction: AvatarEndAction? = null, + isHost: Boolean = false, + overlay: @Composable BoxScope.() -> Unit = { + Image( + modifier = Modifier.padding(5.dp), + imageVector = Icons.Default.Person, + colorFilter = ColorFilter.tint(Color.White), + contentDescription = null, + ) + } +) { + Layout( + modifier = modifier, + content = { + UserAvatar( + modifier = Modifier + .size(size) + .clip(CircleShape) + .layoutId("content"), + data = imageData, + overlay = overlay + ) + + if (isHost) { + Image( + modifier = imageModifier + .layoutId("crown") + .size(getBadgeSize(size)) + .background(color = Color(0xFFE9C432), shape = CircleShape) + .padding(4.dp), + painter = painterResource(R.drawable.ic_crown), + contentDescription = null, + colorFilter = ColorFilter.tint(CodeTheme.colors.brand) + ) + } + + if (endAction != null) { + when (endAction) { + is AvatarEndAction.Icon -> { + Image( + modifier = imageModifier + .layoutId("badge") + .size(getBadgeSize(size)) + .background(color = endAction.backgroundColor, shape = CircleShape) + .padding(6.dp), + painter = endAction.icon, + contentDescription = null, + colorFilter = ColorFilter.tint(endAction.contentColor) + ) + } + } + } + } + ) { measurables, incomingConstraints -> + val constraints = incomingConstraints.copy(minWidth = 0, minHeight = 0) + val contentPlaceable = + measurables.find { it.layoutId == "content" }?.measure(constraints) + val crownPlaceable = + measurables.find { it.layoutId == "crown" }?.measure(constraints) + val badgePlaceable = + measurables.find { it.layoutId == "badge" }?.measure(constraints) + + val maxWidth = widthOrZero(contentPlaceable) + val maxHeight = heightOrZero(contentPlaceable) + + layout(width = maxWidth, height = maxHeight) { + contentPlaceable?.placeRelative(0, 0) + + val avatarSizePx = widthOrZero(contentPlaceable) + val avatarRadius = avatarSizePx / 2f + + crownPlaceable?.let { crown -> + val offset = placeBadgeOnAvatarPerimeter( + placeable = crown, + avatarRadius = avatarRadius, + centerX = avatarRadius, + centerY = avatarRadius, + angleDegrees = 225f + ) + crown.placeRelative(offset.x, offset.y) + } + + badgePlaceable?.let { badge -> + val offset = placeBadgeOnAvatarPerimeter( + placeable = badge, + avatarRadius = avatarRadius, + centerX = avatarRadius, + centerY = avatarRadius, + angleDegrees = 315f + ) + badge.placeRelative(offset.x, offset.y) + } + } + } +} + +private fun getBadgeSize(x: Dp): Dp { + // Using the “point-slope” approach + return 20.dp + (x - 40.dp) * (5f / 35f) + // which simplifies to (x / 7) + (100 / 7) +} + +private fun MeasureScope.placeBadgeOnAvatarPerimeter( + placeable: Placeable?, + avatarRadius: Float, + centerX: Float, + centerY: Float, + angleDegrees: Float +): IntOffset { + val angle = Math.toRadians(angleDegrees.toDouble()).toFloat() + + val badgeRadius = widthOrZero(placeable) / 2f + + val distanceBetweenCenters = avatarRadius + badgeRadius - 8.dp.toPx() + + val badgeCenterX = centerX + distanceBetweenCenters * cos(angle) + val badgeCenterY = centerY + distanceBetweenCenters * sin(angle) + + val offsetX = badgeCenterX - badgeRadius + val offsetY = badgeCenterY - badgeRadius + + return IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) +} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/MessageList.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/MessageList.kt new file mode 100644 index 000000000..464190ef8 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/MessageList.kt @@ -0,0 +1,351 @@ +package com.getcode.ui.components.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.itemContentType +import androidx.paging.compose.itemKey +import com.getcode.model.ID +import com.getcode.model.chat.MessageContent +import com.getcode.model.chat.MessageStatus +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.chat.messagecontents.MessageControlAction +import com.getcode.ui.components.chat.utils.ChatItem +import com.getcode.ui.components.chat.utils.MessageTip +import com.getcode.ui.components.text.markup.Markup +import com.getcode.util.formatDateRelatively +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNot + +sealed interface MessageListEvent { + data class AdvancePointer(val messageId: ID) : MessageListEvent + data class OpenMessageActions(val actions: List) : MessageListEvent + data class OnMarkupEvent(val markup: Markup.Interactive) : MessageListEvent + data class ReplyToMessage(val message: ChatItem.Message) : MessageListEvent + data class ViewOriginalMessage(val messageId: ID, val originalMessageId: ID) : MessageListEvent + data object UnreadStateHandled: MessageListEvent + data class TipMessage(val message: ChatItem.Message): MessageListEvent + data class ShowTipsForMessage(val tips: List): MessageListEvent +} + +data class MessageListPointer( + val current: ChatItem.Message, + val previous: ChatItem.Message?, + val next: ChatItem.Message? +) + +data class MessageListPointerResult( + val isPreviousGrouped: Boolean, + val isNextGrouped: Boolean, +) + +@Composable +fun MessageList( + modifier: Modifier = Modifier, + listState: LazyListState = rememberLazyListState(), + contentStyle: TextStyle = MessageNodeDefaults.ContentStyle, + messages: LazyPagingItems, + handleMessagePointers: (MessageListPointer) -> MessageListPointerResult = { (current, previous, next) -> + val prevGrouped = previous?.chatMessageId == current.chatMessageId + val nextGrouped = next?.chatMessageId == current.chatMessageId + MessageListPointerResult(prevGrouped, nextGrouped) + }, + dispatch: (MessageListEvent) -> Unit = { }, +) { + var hasSetAtUnread by rememberSaveable(key = "0") { mutableStateOf(false) } + + HandleMessageReads(listState, messages, hasSetAtUnread) { + dispatch(MessageListEvent.AdvancePointer(it)) + } + + HandleStartAtUnread(listState, messages, hasSetAtUnread) { + hasSetAtUnread = true + dispatch(MessageListEvent.UnreadStateHandled) + } + + LazyColumn( + modifier = modifier, + state = listState, + reverseLayout = true, + contentPadding = PaddingValues( + vertical = CodeTheme.dimens.inset, + ), + verticalArrangement = Arrangement.Top, + ) { + items( + count = messages.itemCount, + key = messages.itemKey { item -> item.key }, + contentType = messages.itemContentType { item -> + when (item) { + is ChatItem.Date -> "date" + is ChatItem.Message -> "message" + is ChatItem.UnreadSeparator -> "unread_divider" + is ChatItem.Separators -> "separator" + } + } + ) { index -> + when (val item = messages[index]) { + is ChatItem.Date -> DateBubble( + modifier = Modifier + .padding(vertical = CodeTheme.dimens.grid.x2), + date = item.dateString + ) + + is ChatItem.Message -> { + // reverse layout so +1 to get previous + val prev = messages.safeGet(index + 1) as? ChatItem.Message + val next = messages.safeGet(index - 1) as? ChatItem.Message + + val pointerRef = MessageListPointer(item, prev, next) + + val (isPreviousGrouped, isNextGrouped) = handleMessagePointers(pointerRef) + + val spacingBefore = when { + item.message is MessageContent.Announcement -> CodeTheme.dimens.grid.x1 + else -> 0.dp + } + val spacingAfter = when { + index > messages.itemCount -> 0.dp + item.message is MessageContent.Announcement -> CodeTheme.dimens.inset + isNextGrouped -> 3.dp + else -> CodeTheme.dimens.grid.x3 + } + + val showTimestamp = + remember(isPreviousGrouped, isNextGrouped, item.date, next?.date) { + !isPreviousGrouped + || !isNextGrouped + || next?.date?.epochSeconds?.div(60) != item.date.epochSeconds.div(60) + } + + val updatedSender by rememberUpdatedState(item.sender) + val updatedActions by rememberUpdatedState(item.messageControls) + + MessageNode( + modifier = Modifier + .fillMaxWidth() + .padding(top = spacingBefore, bottom = spacingAfter), + contents = item.message, + status = item.status, + isDeleted = item.isDeleted, + deletedBy = item.deletedBy, + sender = updatedSender, + date = item.date, + options = MessageNodeOptions( + showStatus = item.showStatus && showTimestamp, + showTimestamp = showTimestamp, + isPreviousGrouped = isPreviousGrouped, + isNextGrouped = isNextGrouped, + isInteractive = updatedActions.hasAny, + canReplyTo = item.enableReply, + canTip = item.enableTipping, + linkImagePreviewEnabled = item.enableLinkImagePreview, + onMarkupClicked = if (item.enableMarkup) { markup: Markup.Interactive -> + dispatch(MessageListEvent.OnMarkupEvent(markup)) + } else null, + contentStyle = contentStyle, + ), + wasSentAsFullMember = item.wasSentAsFullMember, + tips = item.tips, + openMessageControls = { + dispatch( + MessageListEvent.OpenMessageActions(updatedActions.actions) + ) + }, + showTips = { dispatch(MessageListEvent.ShowTipsForMessage(item.tips)) }, + showTipSelection = { dispatch(MessageListEvent.TipMessage(item)) }, + onReply = { dispatch(MessageListEvent.ReplyToMessage(item)) }, + originalMessage = item.originalMessage, + onViewOriginalMessage = { + dispatch(MessageListEvent.ViewOriginalMessage(item.chatMessageId, it)) + } + ) + } + + is ChatItem.UnreadSeparator -> { + if (item.count > 0) { + UnreadSeparator( + modifier = Modifier + .fillParentMaxWidth() + .padding(vertical = CodeTheme.dimens.grid.x2), + count = item.count + ) + } + } + + is ChatItem.Separators -> { + item.separators.fastForEach { separator -> + when (separator) { + is ChatItem.Date -> { + DateBubble( + modifier = Modifier + .padding(vertical = CodeTheme.dimens.grid.x2), + date = separator.dateString + ) + } + + is ChatItem.UnreadSeparator -> { + if (separator.count > 0) { + UnreadSeparator( + modifier = Modifier + .fillParentMaxWidth() + .padding(vertical = CodeTheme.dimens.grid.x2), + count = separator.count + ) + } + } + } + } + } + + else -> Unit + } + } + // add last separator + // this isn't handled by paging separators due to no `beforeItem` to reference against + // at end of list due to reverseLayout + if (messages.itemCount > 0) { + (messages[messages.itemCount - 1] as? ChatItem.Message)?.date?.let { date -> + item { + val dateString = remember(date) { + date.formatDateRelatively() + } + DateBubble( + modifier = Modifier + .padding(bottom = CodeTheme.dimens.grid.x2), + date = dateString + ) + } + } + } + + // opts out of the list maintaining + // scroll position when adding elements before the first item + // we are checking first visible item index to ensure + // the list doesn't shift when viewing scroll back + Snapshot.withoutReadObservation { + if (listState.firstVisibleItemIndex == 0) { + listState.requestScrollToItem( + index = listState.firstVisibleItemIndex, + scrollOffset = listState.firstVisibleItemScrollOffset + ) + } + } + } +} + +fun LazyPagingItems.safeGet(index: Int): T? { + return if (index in 0 until itemCount) get(index) else null +} + +@Composable +private fun HandleMessageReads( + listState: LazyListState, + messages: LazyPagingItems, + hasSetAtUnread: Boolean, + markAsRead: (ID) -> Unit, +) { + LaunchedEffect(listState, messages, hasSetAtUnread) { + combine( + snapshotFlow { + messages.loadState.prepend is LoadState.NotLoading || + messages.loadState.append is LoadState.NotLoading + }.distinctUntilChanged(), + snapshotFlow { listState.isScrollInProgress }, + snapshotFlow { listState.firstVisibleItemIndex }, + ) { loadState, isScrolling, firstVisibleIndex -> + Triple(loadState, isScrolling, firstVisibleIndex) + }.filter { (loadStateIsNotLoading, isScrolling, _) -> + // Wait until scrolling stops, messages are not loading, and we are at the bottom + loadStateIsNotLoading && !isScrolling && messages.itemCount > 0 && hasSetAtUnread + }.collect { (_, _, firstVisibleIndex) -> + val closestChatMessage = + messages[firstVisibleIndex]?.let { it as? ChatItem.Message } + + val mostRecentReadMessage = + messages.itemSnapshotList.filterIsInstance() + .filter { it.status == MessageStatus.Read } + .maxByOrNull { it.date } + + val mostRecentReadAt = mostRecentReadMessage?.date + + closestChatMessage?.let { message -> + if (message.status != MessageStatus.Read) { + if (mostRecentReadAt == null || message.date >= mostRecentReadAt) { + markAsRead(message.chatMessageId) + } + } + } + } + } +} + +@Composable +private fun HandleStartAtUnread( + listState: LazyListState, + messages: LazyPagingItems, + hasSetAtUnread: Boolean, + onHandled: () -> Unit, +) { + // Flag to ensure scroll logic runs only once + var hasScrolledToUnread by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(listState, messages) { + snapshotFlow { messages.loadState } + .filterNot { hasScrolledToUnread } + .collect { loadState -> + if (loadState.refresh is LoadState.NotLoading && messages.itemCount > 0) { + val separatorIndex = messages.itemSnapshotList + .indexOfFirst { it is ChatItem.UnreadSeparator || (it is ChatItem.Separators && it.separators.any { it is ChatItem.UnreadSeparator }) } + + if (separatorIndex > 0 && !hasSetAtUnread) { + val previousItemIndex = separatorIndex - 1 + + // First scroll to bring the item into view + listState.scrollToItem(previousItemIndex) + + // Dynamically calculate the correct offset + val itemInfo = listState.layoutInfo.visibleItemsInfo + .find { it.index == previousItemIndex } + + itemInfo?.let { + val viewportEnd = listState.layoutInfo.viewportEndOffset + val offsetFromEnd = viewportEnd - (it.offset + it.size) + + // Scroll only if the item isn't sufficiently visible + if (offsetFromEnd > 0) { + listState.scrollToItem(previousItemIndex, scrollOffset = it.offset) + } + } + hasScrolledToUnread = true + onHandled() + } else { + hasScrolledToUnread = true + onHandled() + } + } + } + } +} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/MessageNode.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/MessageNode.kt new file mode 100644 index 000000000..24856fd5d --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/MessageNode.kt @@ -0,0 +1,587 @@ +package com.getcode.ui.components.chat + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.splineBasedDecay +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.BoxWithConstraintsScope +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.getcode.model.ID +import com.getcode.model.chat.Deleter +import com.getcode.model.chat.MessageContent +import com.getcode.model.chat.MessageStatus +import com.getcode.model.chat.Sender +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.chat.messagecontents.ActionableAnnouncementMessage +import com.getcode.ui.components.chat.messagecontents.AnnouncementMessage +import com.getcode.ui.components.chat.messagecontents.DeletedMessage +import com.getcode.ui.components.chat.messagecontents.EncryptedContent +import com.getcode.ui.components.chat.messagecontents.MessagePayment +import com.getcode.ui.components.chat.messagecontents.MessageReplyContent +import com.getcode.ui.components.chat.messagecontents.MessageText +import com.getcode.ui.components.chat.utils.MessageTip +import com.getcode.ui.components.chat.utils.ReplyMessageAnchor +import com.getcode.ui.components.chat.utils.localizedText +import com.getcode.ui.components.text.markup.Markup +import com.getcode.util.vibration.LocalVibrator +import kotlinx.datetime.Instant +import kotlin.math.roundToInt +import kotlin.reflect.KClass + +object MessageNodeDefaults { + + val DefaultShape: CornerBasedShape + @Composable get() = CodeTheme.shapes.small + + private val PreviousSameShapeIncoming: CornerBasedShape + @Composable get() = DefaultShape.copy(topStart = CornerSize(3.dp)) + + private val NextSameShapeIncoming: CornerBasedShape + @Composable get() = DefaultShape.copy( + bottomStart = CornerSize(3.dp), + topStart = CornerSize(3.dp) + ) + + private val MiddleSameShapeIncoming: CornerBasedShape + @Composable get() = DefaultShape.copy( + topStart = CornerSize(3.dp), + bottomStart = CornerSize(3.dp) + ) + + private val PreviousSameShapeOutgoing: CornerBasedShape + @Composable get() = DefaultShape.copy(topEnd = CornerSize(3.dp)) + + private val NextSameShapeOutgoing: CornerBasedShape + @Composable get() = DefaultShape.copy( + bottomEnd = CornerSize(3.dp), + topEnd = CornerSize(3.dp) + ) + + private val MiddleSameShapeOutgoing: CornerBasedShape + @Composable get() = DefaultShape.copy( + bottomEnd = CornerSize(3.dp), + topEnd = CornerSize(3.dp) + ) + + @Composable + fun messageShape( + isIncoming: Boolean, + isPreviousInGroup: Boolean, + isNextInGroup: Boolean, + ): Shape { + return if (isIncoming) { + when { + isPreviousInGroup && isNextInGroup -> MiddleSameShapeIncoming + isPreviousInGroup -> PreviousSameShapeIncoming + isNextInGroup -> NextSameShapeIncoming + else -> DefaultShape.copy(topStart = CornerSize(3.dp)) + } + } else { + when { + isPreviousInGroup && isNextInGroup -> MiddleSameShapeOutgoing + isPreviousInGroup -> PreviousSameShapeOutgoing + isNextInGroup -> NextSameShapeOutgoing + else -> DefaultShape.copy(topEnd = CornerSize(3.dp)) + } + } + } + + val ContentStyle: TextStyle + @Composable get() = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.W500) + + val SwipeThreshold + @Composable get() = 25.dp +} + +class MessageNodeScope( + private val contents: MessageContent, + private val boxScope: BoxWithConstraintsScope +) { + fun Modifier.sizeableWidth() = + this.widthIn(max = boxScope.maxWidth * 0.85f) + + val isAnnouncement: Boolean + @Composable get() = remember { + when (contents) { + is MessageContent.Announcement -> true + is MessageContent.ActionableAnnouncement -> true + else -> false + } + } + + val color: Color + @Composable get() = when { + isAnnouncement -> CodeTheme.colors.secondary + contents.isFromSelf -> CodeTheme.colors.secondary + else -> CodeTheme.colors.brandDark + } +} + +@Composable +private fun rememberMessageNodeScope( + contents: MessageContent, + boxScope: BoxWithConstraintsScope +): MessageNodeScope { + return remember(contents, boxScope) { + MessageNodeScope(contents, boxScope) + } +} + +data class MessageNodeOptions( + val showStatus: Boolean = true, + val showTimestamp: Boolean = true, + val isPreviousGrouped: Boolean = false, + val isNextGrouped: Boolean = false, + val isInteractive: Boolean = false, + val canReplyTo: Boolean = false, + val canTip: Boolean = false, + val linkImagePreviewEnabled: Boolean = false, + val markupsToResolve: List> = listOf( + Markup.RoomNumber::class, + Markup.Url::class, + Markup.Phone::class, + ), + val onMarkupClicked: ((Markup.Interactive) -> Unit)? = null, + val contentStyle: TextStyle, +) + +private enum class MessageNodeDragAnchors { + DEFAULT, REPLY +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MessageNode( + contents: MessageContent, + isDeleted: Boolean, + deletedBy: Deleter?, + date: Instant, + sender: Sender, + status: MessageStatus, + originalMessage: ReplyMessageAnchor?, + modifier: Modifier = Modifier, + options: MessageNodeOptions = MessageNodeOptions(contentStyle = MessageNodeDefaults.ContentStyle), + tips: List, + wasSentAsFullMember: Boolean = true, + openMessageControls: () -> Unit, + showTipSelection: () -> Unit, + showTips: () -> Unit, + onReply: () -> Unit, + onViewOriginalMessage: (ID) -> Unit, +) { + val vibrator = LocalVibrator.current + + val enableReply = remember(contents, options) { + if (!options.canReplyTo) return@remember false + when (contents) { + is MessageContent.RawText -> true + is MessageContent.Reply -> true + else -> false + } + } + + val density = LocalDensity.current + + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val maxWidth = maxWidth + val swipeThreshold = with(density) { maxWidth.toPx() } * 0.40f + var hasReachedThreshold by remember { mutableStateOf(false) } + + val anchors = remember(maxWidth) { + DraggableAnchors { + MessageNodeDragAnchors.DEFAULT at 0f + MessageNodeDragAnchors.REPLY at swipeThreshold + } + } + + val replyDragState = remember(anchors) { + AnchoredDraggableState( + initialValue = MessageNodeDragAnchors.DEFAULT, + anchors = anchors, + positionalThreshold = { it * 0.9f }, + velocityThreshold = { Float.POSITIVE_INFINITY }, + confirmValueChange = { targetValue -> + if (targetValue == MessageNodeDragAnchors.REPLY && !hasReachedThreshold) { + hasReachedThreshold = true + vibrator.tick() + } + false + }, + snapAnimationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + decayAnimationSpec = splineBasedDecay(density) + ) + } + + LaunchedEffect(hasReachedThreshold, replyDragState.targetValue) { + if (hasReachedThreshold && replyDragState.targetValue == MessageNodeDragAnchors.DEFAULT && replyDragState.isAnimationRunning) { + onReply() + hasReachedThreshold = false + } + } + + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + // Block vertical scrolling only when horizontal drag is active + return if (replyDragState.offset != 0f) Offset.Zero else super.onPreScroll( + available, + source + ) + } + } + } + + Box( + modifier = Modifier + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + clip = false + } + .nestedScroll(nestedScrollConnection) + .anchoredDraggable( + state = replyDragState, + enabled = enableReply, + orientation = Orientation.Horizontal, + reverseDirection = false + ) + ) { + Box( + modifier = Modifier + .offset { + IntOffset( + x = replyDragState.offset.coerceAtMost(maxWidth.toPx() * 0.3f) + .roundToInt(), + y = 0 + ) + } + ) { + val scope = rememberMessageNodeScope( + contents = contents, + boxScope = this@BoxWithConstraints + ) + + with(scope) { + val shape = when { + isAnnouncement -> MessageNodeDefaults.DefaultShape + else -> MessageNodeDefaults.messageShape( + !sender.isSelf, options.isPreviousGrouped, options.isNextGrouped + ) + } + + if (isDeleted) { + ContentFromSender( + modifier = Modifier.fillMaxWidth(), + sender = sender, + isFirstInSeries = !options.isPreviousGrouped + ) { + DeletedMessage( + modifier = Modifier.fillMaxWidth(), + sender = sender, + deletedBy = deletedBy, + shape = shape, + date = date, + ) + } + } else { + when (contents) { + is MessageContent.Exchange -> { + val alignment = + if (sender.isSelf) Alignment.CenterEnd else Alignment.CenterStart + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = alignment + ) { + MessagePayment( + modifier = Modifier + .sizeableWidth() + .background(color = color, shape = shape), + contents = contents, + status = status, + date = date, + ) + } + } + + is MessageContent.Localized -> { + ContentFromSender( + modifier = Modifier.fillMaxWidth(), + sender = sender, + isFirstInSeries = !options.isPreviousGrouped + ) { + MessageText( + content = contents.localizedText, + shape = shape, + date = date, + status = status, + isFromSelf = sender.isSelf, + isFromBlockedMember = sender.isBlocked, + options = options, + wasSentAsFullMember = wasSentAsFullMember, + tips = tips, + showTips = showTips, + showControls = openMessageControls, + showTipSelection = showTipSelection, + ) + } + } + + is MessageContent.SodiumBox -> { + val alignment = + if (sender.isSelf) Alignment.CenterEnd else Alignment.CenterStart + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = alignment + ) { + EncryptedContent( + modifier = Modifier + .sizeableWidth() + .background( + color = color, + shape = shape, + ) + .padding(CodeTheme.dimens.grid.x2), + date = date + ) + } + } + + is MessageContent.Decrypted -> { + ContentFromSender( + modifier = Modifier.fillMaxWidth(), + sender = sender, + isFirstInSeries = !options.isPreviousGrouped + ) { + MessageText( + content = contents.data, + shape = shape, + date = date, + status = status, + isFromSelf = sender.isSelf, + isFromBlockedMember = sender.isBlocked, + options = options, + wasSentAsFullMember = wasSentAsFullMember, + tips = tips, + showTips = showTips, + showControls = openMessageControls, + showTipSelection = showTipSelection, + ) + } + } + + is MessageContent.RawText -> { + ContentFromSender( + modifier = Modifier.fillMaxWidth(), + sender = sender, + isFirstInSeries = !options.isPreviousGrouped + ) { + MessageText( + content = contents.value, + shape = shape, + date = date, + status = status, + isFromSelf = sender.isSelf, + isFromBlockedMember = sender.isBlocked, + options = options, + wasSentAsFullMember = wasSentAsFullMember, + tips = tips, + showTips = showTips, + showControls = openMessageControls, + showTipSelection = showTipSelection, + ) + } + } + + is MessageContent.ActionableAnnouncement -> { + ActionableAnnouncementMessage( + modifier = Modifier.fillMaxWidth(), + text = contents.localizedText, + action = contents.action, + ) + } + is MessageContent.Announcement -> { + AnnouncementMessage( + modifier = Modifier.fillMaxWidth(), + text = contents.localizedText + ) + } + + is MessageContent.Reaction -> Unit + is MessageContent.Reply -> { + ContentFromSender( + modifier = Modifier.fillMaxWidth(), + sender = sender, + isFirstInSeries = !options.isPreviousGrouped + ) { + if (originalMessage != null) { + MessageReplyContent( + content = contents.text, + shape = shape, + date = date, + status = status, + isFromSelf = sender.isSelf, + isFromBlockedMember = sender.isBlocked, + options = options, + tips = tips, + showTips = showTips, + showControls = openMessageControls, + showTipSelection = showTipSelection, + originalMessage = originalMessage, + onOriginalMessageClicked = { + onViewOriginalMessage(originalMessage.id) + } + ) + } else { + MessageText( + content = contents.text, + shape = shape, + date = date, + status = status, + isFromSelf = sender.isSelf, + isFromBlockedMember = sender.isBlocked, + options = options, + wasSentAsFullMember = wasSentAsFullMember, + tips = tips, + showTips = showTips, + showControls = openMessageControls, + showTipSelection = showTipSelection, + ) + } + } + } + + is MessageContent.DeletedMessage -> Unit + is MessageContent.Unknown -> Unit + is MessageContent.MessageTip -> Unit + is MessageContent.MessageInReview -> { + // TODO: + } + } + } + } + } + + AnimatedVisibility( + visible = replyDragState.offset > with(density) { 5.dp.toPx() }, + enter = fadeIn() + scaleIn(), + exit = scaleOut() + fadeOut(), + modifier = Modifier + .align(Alignment.CenterStart) + .offset { + IntOffset( + x = replyDragState.offset.times(0.2f).coerceAtMost(20.dp.toPx()) + .roundToInt(), y = 0 + ) + } + ) { + Icon( + modifier = Modifier.graphicsLayer { + alpha = (replyDragState.offset.times(0.2f) / 20.dp.toPx()).coerceIn(0f, 1f) + }, + imageVector = Icons.AutoMirrored.Default.Reply, + contentDescription = "Swipe to Reply", + ) + } + } + } +} + +@Composable +private fun ContentFromSender( + modifier: Modifier = Modifier, + sender: Sender, + isFirstInSeries: Boolean, + content: @Composable () -> Unit +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2) + ) { + if (!sender.isSelf) { + if (isFirstInSeries) { + Column { + Spacer(Modifier.size(CodeTheme.dimens.grid.x4)) + HostableAvatar( + modifier = Modifier.padding( + top = CodeTheme.dimens.grid.x1, + start = CodeTheme.dimens.inset + ), + imageData = sender.profileImage ?: sender.id, + isHost = sender.isHost + ) + } + } else { + Spacer( + modifier = Modifier + .padding(start = CodeTheme.dimens.inset) + .size(CodeTheme.dimens.staticGrid.x8)) + } + } + Box(modifier = Modifier.weight(1f)) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1) + ) { + if (!sender.isSelf && isFirstInSeries) { + Text( + text = sender.displayName.orEmpty(), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.tertiary + ) + } + content() + } + } + Spacer(Modifier.width(CodeTheme.dimens.inset)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/chat/TypingIndicator.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt similarity index 98% rename from app/src/main/java/com/getcode/ui/components/chat/TypingIndicator.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt index f8c74271b..66abe340e 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/TypingIndicator.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.getcode.theme.CodeTheme +import com.getcode.theme.DesignSystem import kotlinx.coroutines.delay private const val StepDuration = 400 @@ -91,7 +92,7 @@ fun TypingIndicator( @Composable @Preview fun PreviewTypingIndicator() { - CodeTheme { + DesignSystem { Box(modifier = Modifier.size(400.dp), contentAlignment = Alignment.Center) { TypingIndicator() } diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/UnreadSeparator.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/UnreadSeparator.kt new file mode 100644 index 000000000..dd80065e7 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/UnreadSeparator.kt @@ -0,0 +1,27 @@ +package com.getcode.ui.components.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.R + +@Composable +internal fun UnreadSeparator(count: Int, modifier: Modifier = Modifier) { + Box(modifier = modifier.background(CodeTheme.colors.divider)) { + Text( + modifier = Modifier + .padding(vertical = CodeTheme.dimens.grid.x2) + .align(Alignment.Center), + text = pluralStringResource(R.plurals.title_conversationUnreadCount, count, count), + style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.W700), + color = CodeTheme.colors.textSecondary + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/chat/UserAvatar.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/UserAvatar.kt similarity index 50% rename from app/src/main/java/com/getcode/ui/components/chat/UserAvatar.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/chat/UserAvatar.kt index aae9d4422..0d2f15af5 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/UserAvatar.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/UserAvatar.kt @@ -1,22 +1,27 @@ package com.getcode.ui.components.chat import androidx.compose.foundation.Image -import androidx.compose.foundation.border +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Size +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp import coil3.annotation.ExperimentalCoilApi import coil3.compose.AsyncImage -import com.getcode.R +import coil3.request.ImageRequest +import coil3.request.allowHardware +import coil3.request.crossfade import com.getcode.theme.CodeTheme +import com.getcode.ui.components.R import java.util.UUID @OptIn(ExperimentalCoilApi::class) @@ -24,16 +29,26 @@ import java.util.UUID fun UserAvatar( data: Any?, modifier: Modifier = Modifier, - anonymousType: AnonymousRender = AnonymousRender.Gradient + anonymousRender: AnonymousRender = AnonymousRender.Gradient, + showEmptyWhenUnknownData: Boolean = true, + overlay: @Composable BoxScope.() -> Unit = { }, ) { Box(modifier = modifier) { - var imgLoading by remember(data) { mutableStateOf(true) } + var imgLoading by rememberSaveable(data) { mutableStateOf(true) } var loadedSize by remember(data) { mutableStateOf(Size.Zero) } - var isError by remember(data) { mutableStateOf(false) } + var isError by rememberSaveable(data) { mutableStateOf(false) } + val context = LocalContext.current + val request = remember(data) { + ImageRequest.Builder(context) + .crossfade(true) + .data(data) + .allowHardware(true) + .build() + } AsyncImage( modifier = Modifier.matchParentSize(), - model = data, + model = request, contentDescription = null, onError = { isError = true @@ -42,13 +57,13 @@ fun UserAvatar( imgLoading = true }, onSuccess = { - loadedSize = with (it.result.image) { Size(width.toFloat(), height.toFloat()) } + loadedSize = with(it.result.image) { Size(width.toFloat(), height.toFloat()) } imgLoading = false } ) if (imgLoading) { - Box(modifier = Modifier.matchParentSize()) + Box(modifier = Modifier.matchParentSize().background(CodeTheme.colors.brandDark)) } if (isError) { @@ -57,21 +72,27 @@ fun UserAvatar( AnonymousAvatar( modifier = Modifier.matchParentSize(), data = data as List, - type = anonymousType + type = anonymousRender, + overlay = overlay, ) } } else if (data is UUID) { AnonymousAvatar( modifier = Modifier.matchParentSize(), memberId = data, - type = anonymousType + type = anonymousRender, + icon = overlay, ) } else { - Image( - modifier = Modifier.matchParentSize(), - painter = painterResource(id = R.drawable.ic_placeholder_user), - contentDescription = null - ) + if (!showEmptyWhenUnknownData) { + Image( + modifier = Modifier.matchParentSize(), + painter = painterResource(id = R.drawable.ic_placeholder_user), + contentDescription = null + ) + } else { + Spacer(Modifier.matchParentSize()) + } } } } diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/AnnouncementMessage.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/AnnouncementMessage.kt new file mode 100644 index 000000000..24964b285 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/AnnouncementMessage.kt @@ -0,0 +1,101 @@ +package com.getcode.ui.components.chat.messagecontents + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight.Companion.W500 +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.getcode.model.chat.AnnouncementAction +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.Pill +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton + +@Composable +internal fun AnnouncementMessage( + modifier: Modifier = Modifier, + text: String, +) { + BoxWithConstraints(modifier = modifier) { + Pill( + modifier = Modifier + .align(Alignment.Center) + .widthIn(max = maxWidth * 0.78f), + text = text, + textStyle = CodeTheme.typography.caption.copy(textAlign = TextAlign.Center), + backgroundColor = CodeTheme.colors.surfaceVariant, + contentColor = CodeTheme.colors.textSecondary + ) + } +} + +@Composable +internal fun ActionableAnnouncementMessage( + modifier: Modifier = Modifier, + text: String, + action: AnnouncementAction, +) { + val resolver = LocalAnnouncementActionResolver.current + val resolvedAction = remember(resolver, action) { resolver(action) } + + val inset = CodeTheme.dimens.inset + + BoxWithConstraints(modifier = modifier) { + BoxWithConstraints( + modifier = Modifier + .align(Alignment.Center) + .widthIn(max = maxWidth - inset - inset) // max width sans inset on both sides + .border( + color = CodeTheme.colors.tertiary, + width = 1.dp, + shape = CodeTheme.shapes.medium, + ) + .padding( + horizontal = CodeTheme.dimens.grid.x2, + vertical = CodeTheme.dimens.grid.x4 + ) + ) contents@{ + Column( + modifier = Modifier + // add top padding to accommodate ascents + .padding(top = CodeTheme.dimens.grid.x1), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset), + ) { + + Text( + text = text, + style = CodeTheme.typography.textSmall.copy(textAlign = TextAlign.Center), + color = CodeTheme.colors.textSecondary, + + ) + + if (resolvedAction != null) { + CodeButton( + modifier = Modifier.fillMaxWidth(), + text = resolvedAction.text, + buttonState = ButtonState.Filled, + ) { resolvedAction.onClick() } + } + } + } + } +} + +data class ResolvedAction( + val text: String, + val onClick: () -> Unit +) + +val LocalAnnouncementActionResolver: ProvidableCompositionLocal<(AnnouncementAction) -> ResolvedAction?> = staticCompositionLocalOf { { null } } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/chat/DateWithStatus.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/DateWithStatus.kt similarity index 79% rename from app/src/main/java/com/getcode/ui/components/chat/DateWithStatus.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/DateWithStatus.kt index cbb21794d..f21d1bc30 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/DateWithStatus.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/DateWithStatus.kt @@ -1,4 +1,4 @@ -package com.getcode.ui.components.chat +package com.getcode.ui.components.chat.messagecontents import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -11,15 +11,18 @@ import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp -import com.getcode.R -import com.getcode.model.MessageStatus +import com.getcode.model.chat.MessageStatus import com.getcode.theme.CodeTheme +import com.getcode.theme.DesignSystem +import com.getcode.ui.components.R +import com.getcode.ui.components.chat.MessageNodeDefaults import com.getcode.util.formatTimeRelatively import kotlinx.datetime.Clock import kotlinx.datetime.Instant @@ -38,20 +41,24 @@ internal fun DateWithStatus( modifier: Modifier = Modifier, date: Instant, isFromSelf: Boolean, - status: MessageStatus + showStatus: Boolean = true, + showTimestamp: Boolean = true, + status: MessageStatus, ) { Row( modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(DateWithStatusDefaults.Spacing), + horizontalArrangement = Arrangement.spacedBy(DateWithStatusDefaults.Spacing, Alignment.End), ) { - Text( - modifier = Modifier.weight(1f, fill = false), - text = date.formatTimeRelatively(), - style = DateWithStatusDefaults.DateTextStyle, - color = CodeTheme.colors.textSecondary, - maxLines = 1 - ) - if (status.isValid() && isFromSelf) { + if (showTimestamp) { + Text( + modifier = Modifier.weight(1f, fill = false), + text = date.formatTimeRelatively(), + style = DateWithStatusDefaults.DateTextStyle, + color = CodeTheme.colors.textSecondary, + maxLines = 1 + ) + } + if (status.isValid() && isFromSelf && showStatus) { Icon( modifier = Modifier .requiredWidth(width = DateWithStatusDefaults.IconWidth) @@ -77,7 +84,7 @@ internal fun DateWithStatus( @Preview @Composable private fun Preview_DateWithStatus() { - CodeTheme { + DesignSystem { @Composable fun Bubble( status: MessageStatus diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/DeletedMessage.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/DeletedMessage.kt new file mode 100644 index 000000000..c14611368 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/DeletedMessage.kt @@ -0,0 +1,95 @@ +package com.getcode.ui.components.chat.messagecontents + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.getcode.model.ID +import com.getcode.model.chat.Deleter +import com.getcode.model.chat.MessageStatus +import com.getcode.model.chat.Sender +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.R +import com.getcode.ui.components.chat.MessageNodeDefaults +import com.getcode.ui.components.chat.MessageNodeOptions +import com.getcode.ui.components.chat.MessageNodeScope +import kotlinx.datetime.Instant + +@Composable +internal fun MessageNodeScope.DeletedMessage( + modifier: Modifier = Modifier, + sender: Sender, + shape: Shape, + deletedBy: Deleter?, + date: Instant, +) { + val alignment = if (sender.isSelf) Alignment.CenterEnd else Alignment.CenterStart + + BoxWithConstraints(modifier = modifier.fillMaxWidth(), contentAlignment = alignment) { + BoxWithConstraints( + modifier = Modifier + .sizeableWidth() + .border( + color = CodeTheme.colors.tertiary, + width = 1.dp, + shape = shape, + ) + .padding(CodeTheme.dimens.grid.x2) + ) contents@{ + val maxWidthPx = with(LocalDensity.current) { maxWidth.roundToPx() } + Column( + modifier = Modifier + // add top padding to accommodate ascents + .padding(top = CodeTheme.dimens.grid.x1), + ) { + + val message = buildAnnotatedString { + when { + deletedBy?.isSelf == true -> { + pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) + append(stringResource(R.string.title_messageDeletedByYou)) + pop() + } + deletedBy?.isHost == true -> { + pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) + append(stringResource(R.string.title_messageDeletedByHost)) + pop() + } + else -> { + pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) + append(stringResource(R.string.title_messageWasDeleted)) + pop() + } + } + } + + MessageContent( + maxWidth = maxWidthPx, + annotatedMessage = message, + date = date, + status = MessageStatus.Unknown, + isFromSelf = sender.isSelf, + isFromBlockedMember = sender.isBlocked, + options = MessageNodeOptions( + contentStyle = MessageNodeDefaults.ContentStyle.copy( + color = CodeTheme.colors.textSecondary, + fontWeight = FontWeight.W400, + ) + ), + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/chat/EncryptedContent.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/EncryptedContent.kt similarity index 93% rename from app/src/main/java/com/getcode/ui/components/chat/EncryptedContent.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/EncryptedContent.kt index e692b2f53..e7865819c 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/EncryptedContent.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/EncryptedContent.kt @@ -1,4 +1,4 @@ -package com.getcode.ui.components.chat +package com.getcode.ui.components.chat.messagecontents import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column @@ -10,9 +10,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource -import com.getcode.R -import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme +import com.getcode.ui.components.R import com.getcode.util.formatTimeRelatively import kotlinx.datetime.Instant diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessageControlAction.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessageControlAction.kt new file mode 100644 index 000000000..9d35e2d1c --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessageControlAction.kt @@ -0,0 +1,141 @@ +package com.getcode.ui.components.chat.messagecontents + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Flag +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PersonRemove +import androidx.compose.material.icons.filled.RecordVoiceOver +import androidx.compose.material.icons.filled.VoiceOverOff +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.getcode.ui.components.R +import com.getcode.ui.components.contextmenu.ContextMenuAction + +data class MessageControls( + val actions: List = emptyList() +) { + val hasAny: Boolean + get() = actions.isNotEmpty() +} + + +sealed interface MessageControlAction : ContextMenuAction { + data class Copy(override val onSelect: () -> Unit) : MessageControlAction { + override val isDestructive: Boolean = false + override val delayUponSelection: Boolean = false + + override val title: String + @Composable get() = stringResource(R.string.action_copyMessage) + + override val painter: Painter + @Composable get() = rememberVectorPainter(Icons.Default.ContentCopy) + } + + data class Reply(override val onSelect: () -> Unit) : MessageControlAction { + override val isDestructive: Boolean = false + override val delayUponSelection: Boolean = false + + override val title: String + @Composable get() = stringResource(R.string.action_reply) + override val painter: Painter + @Composable get() = rememberVectorPainter(Icons.AutoMirrored.Default.Reply) + } + + data class Tip(override val onSelect: () -> Unit) : MessageControlAction { + override val isDestructive: Boolean = false + override val title: String + @Composable get() = stringResource(R.string.action_giveTip) + override val painter: Painter + @Composable get() = painterResource(R.drawable.ic_kin_white_small) + override val delayUponSelection: Boolean = true + } + + data class Delete(override val onSelect: () -> Unit) : MessageControlAction { + override val isDestructive: Boolean = true + override val title: String + @Composable get() = stringResource(R.string.action_deleteMessage) + override val painter: Painter + @Composable get() = rememberVectorPainter(Icons.Default.Delete) + override val delayUponSelection: Boolean = false + } + + data class RemoveUser(val name: String, override val onSelect: () -> Unit) : + MessageControlAction { + override val isDestructive: Boolean = true + override val title: String + @Composable get() = stringResource(R.string.action_removeUser, name) + override val painter: Painter + @Composable get() = rememberVectorPainter(Icons.Default.PersonRemove) + override val delayUponSelection: Boolean = false + } + + data class PromoteUser(override val onSelect: () -> Unit) : + MessageControlAction { + override val title: String + @Composable get() = stringResource(R.string.action_promote) + override val painter: Painter + @Composable get() = rememberVectorPainter(Icons.Default.RecordVoiceOver) + override val delayUponSelection: Boolean = false + override val isDestructive: Boolean = false + } + + data class DemoteUser(override val onSelect: () -> Unit) : + MessageControlAction { + override val title: String + @Composable get() = stringResource(R.string.action_demote) + override val painter: Painter + @Composable get() = rememberVectorPainter(Icons.Default.VoiceOverOff) + override val delayUponSelection: Boolean = false + override val isDestructive: Boolean = false + } + + data class MuteUser(override val onSelect: () -> Unit) : + MessageControlAction { + override val isDestructive: Boolean = true + + override val title: String + @Composable get() = stringResource(R.string.action_muteUser) + + override val painter: Painter + @Composable get() = rememberVectorPainter(Icons.Default.VoiceOverOff) + + override val delayUponSelection: Boolean = false + } + + data class ReportUserForMessage(val name: String, override val onSelect: () -> Unit) : + MessageControlAction { + override val isDestructive: Boolean = true + override val title: String + @Composable get() = stringResource(R.string.action_report) + override val painter: Painter + @Composable get() = rememberVectorPainter(Icons.Default.Flag) + override val delayUponSelection: Boolean = false + } + + data class BlockUser(override val onSelect: () -> Unit) : + MessageControlAction { + override val isDestructive: Boolean = true + override val title: String + @Composable get() = stringResource(R.string.action_blockUser) + override val painter: Painter + @Composable get() = rememberVectorPainter(Icons.Default.Block) + override val delayUponSelection: Boolean = false + } + + data class UnblockUser(override val onSelect: () -> Unit) : + MessageControlAction { + override val isDestructive: Boolean = false + override val title: String + @Composable get() = stringResource(R.string.action_unblockUser) + override val painter: Painter + @Composable get() = rememberVectorPainter(Icons.Default.Person) + override val delayUponSelection: Boolean = false + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/chat/MessagePayment.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessagePayment.kt similarity index 93% rename from app/src/main/java/com/getcode/ui/components/chat/MessagePayment.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessagePayment.kt index ddf7bbe36..718264608 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/MessagePayment.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessagePayment.kt @@ -1,4 +1,4 @@ -package com.getcode.ui.components.chat +package com.getcode.ui.components.chat.messagecontents import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -15,14 +15,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.getcode.LocalExchange import com.getcode.model.chat.MessageContent -import com.getcode.model.MessageStatus +import com.getcode.model.chat.MessageStatus import com.getcode.model.chat.Verb import com.getcode.model.orOneToOne +import com.getcode.network.exchange.LocalExchange import com.getcode.theme.CodeTheme +import com.getcode.ui.components.PriceWithFlag import com.getcode.ui.components.chat.utils.localizedText -import com.getcode.view.main.scanner.components.PriceWithFlag import kotlinx.datetime.Instant @Composable @@ -31,6 +31,7 @@ internal fun MessagePayment( contents: MessageContent.Exchange, date: Instant, status: MessageStatus = MessageStatus.Unknown, + showStatus: Boolean = true, ) { Column( modifier = modifier @@ -99,6 +100,7 @@ internal fun MessagePayment( date = date, status = status, isFromSelf = contents.isFromSelf, + showStatus = showStatus ) } } \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessageReplyContent.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessageReplyContent.kt new file mode 100644 index 000000000..921ba4416 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessageReplyContent.kt @@ -0,0 +1,223 @@ +package com.getcode.ui.components.chat.messagecontents + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.getcode.model.chat.MessageStatus +import com.getcode.theme.CodeTheme +import com.getcode.theme.extraSmall +import com.getcode.ui.components.R +import com.getcode.ui.components.chat.MessageNodeDefaults +import com.getcode.ui.components.chat.MessageNodeOptions +import com.getcode.ui.components.chat.MessageNodeScope +import com.getcode.ui.components.chat.utils.MessageTip +import com.getcode.ui.components.chat.utils.ReplyMessageAnchor +import com.getcode.ui.components.chat.utils.localizedText +import com.getcode.ui.utils.addIf +import com.getcode.ui.utils.generateComplementaryColorPalette +import com.getcode.ui.utils.rememberedLongClickable +import kotlinx.datetime.Instant + +@Composable +internal fun MessageNodeScope.MessageReplyContent( + modifier: Modifier = Modifier, + content: String, + originalMessage: ReplyMessageAnchor, + onOriginalMessageClicked: () -> Unit, + shape: Shape = MessageNodeDefaults.DefaultShape, + options: MessageNodeOptions, + isFromSelf: Boolean, + isFromBlockedMember: Boolean, + date: Instant, + status: MessageStatus = MessageStatus.Unknown, + tips: List, + showTips: () -> Unit, + showControls: () -> Unit, + showTipSelection: () -> Unit +) { + val alignment = if (isFromSelf) Alignment.CenterEnd else Alignment.CenterStart + + Box(modifier = modifier.fillMaxWidth(), contentAlignment = alignment) { + Box( + modifier = Modifier + .sizeableWidth() + .background( + color = color, + shape = shape, + ) + .addIf(options.isInteractive) { + Modifier.rememberedLongClickable { + showControls() + } + } + .padding(CodeTheme.dimens.grid.x2) + ) { + SubcomposeLayout { constraints -> + val spacing = 2.5.dp.roundToPx() + + val messageContentPlaceable = subcompose("MessageContent") { + MessageContent( + maxWidth = constraints.maxWidth, + message = content, + date = date, + status = status, + isFromSelf = isFromSelf, + isFromBlockedMember = isFromBlockedMember, + options = options, + tips = tips, + openTipModal = showTips, + onLongPress = showControls, + onDoubleClick = showTipSelection, + ) + }.first().measure(constraints) + + val replyPreviewPlaceable = subcompose("MessageReplyPreview") { + MessageReplyPreview( + modifier = Modifier + .widthIn(min = messageContentPlaceable.width.toDp()) + .pointerInput(Unit) { + detectTapGestures( + onLongPress = if (!options.isInteractive) null else { + { showControls() } + }, + onDoubleTap = { if (options.canTip) showTipSelection() }, + onTap = { onOriginalMessageClicked() } + ) + }, + originalMessage = originalMessage, + backgroundColor = Color.Black.copy(0.1f), + ) + }.first().measure( + constraints.copy(minWidth = messageContentPlaceable.width, maxWidth = constraints.maxWidth) + ) + + // Determine the final width based on the longer of the two components + val finalWidth = maxOf(messageContentPlaceable.width, replyPreviewPlaceable.width) + + // Remeasure MessageContent with the updated width + val remeasuredMessageContentPlaceable = subcompose("MessageContentRemeasured") { + MessageContent( + maxWidth = finalWidth, + minWidth = finalWidth, + message = content, + date = date, + status = status, + isFromSelf = isFromSelf, + isFromBlockedMember = isFromBlockedMember, + options = options, + tips = tips, + openTipModal = showTips, + onLongPress = showControls, + onDoubleClick = showTipSelection, + ) + }.first().measure( + constraints.copy(minWidth = finalWidth, maxWidth = finalWidth) + ) + + // Calculate the total height + val totalHeight = replyPreviewPlaceable.height + spacing + remeasuredMessageContentPlaceable.height + + // Layout the components + layout(finalWidth, totalHeight) { + replyPreviewPlaceable.place(0, 0) + remeasuredMessageContentPlaceable.place(0, replyPreviewPlaceable.height + spacing) + } + } + } + } +} + +@Composable +fun MessageReplyPreview( + originalMessage: ReplyMessageAnchor, + modifier: Modifier = Modifier, + backgroundColor: Color = Color.Transparent, + +) { + val colors = generateComplementaryColorPalette(originalMessage.sender.id!!) + Row( + modifier = modifier + .width(IntrinsicSize.Max) + .height(IntrinsicSize.Min) + .background(backgroundColor, CodeTheme.shapes.extraSmall) + .clip(CodeTheme.shapes.extraSmall), + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .width(3.dp) + .background(colors?.first ?: CodeTheme.colors.tertiary) + ) + Column( + modifier = Modifier + .fillMaxHeight() + .padding(horizontal = CodeTheme.dimens.grid.x1) + .padding(vertical = CodeTheme.dimens.grid.x1) + .weight(1f) + ) { + Text( + text = originalMessage.sender.displayName.orEmpty() + .ifEmpty { "Member" }, + color = colors?.second ?: CodeTheme.colors.tertiary, + style = CodeTheme.typography.textSmall + ) + + val messageBody = when { + originalMessage.isDeleted -> { + val deletionMessage = when { + originalMessage.deletedBy?.isSelf == true -> stringResource(R.string.title_messageDeletedByYou) + originalMessage.deletedBy?.isHost == true -> stringResource(R.string.title_messageDeletedByHost) + else -> stringResource(R.string.title_messageWasDeleted) + } + AnnotatedString.Builder().apply { + pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) + append(deletionMessage) + pop() + }.toAnnotatedString() + } + + originalMessage.sender.isBlocked -> { + AnnotatedString.Builder().apply { + pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) + append(stringResource(R.string.title_blockedMessage)) + pop() + }.toAnnotatedString() + } + + else -> AnnotatedString(originalMessage.message.localizedText) + } + + Text( + text = messageBody, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = CodeTheme.colors.textMain, + style = CodeTheme.typography.caption + ) + } + } +} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessageTextContent.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessageTextContent.kt new file mode 100644 index 000000000..3eec36913 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessageTextContent.kt @@ -0,0 +1,520 @@ +package com.getcode.ui.components.chat.messagecontents + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.PaintingStyle +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import androidx.compose.ui.zIndex +import coil3.compose.AsyncImage +import com.getcode.extensions.formattedRaw +import com.getcode.libs.opengraph.LocalOpenGraphParser +import com.getcode.libs.opengraph.callback.OpenGraphCallback +import com.getcode.libs.opengraph.model.OpenGraphResult +import com.getcode.model.chat.MessageStatus +import com.getcode.model.chat.Sender +import com.getcode.model.sum +import com.getcode.theme.CodeTheme +import com.getcode.theme.DashEffect +import com.getcode.ui.components.R +import com.getcode.ui.components.chat.MessageNodeDefaults +import com.getcode.ui.components.chat.MessageNodeOptions +import com.getcode.ui.components.chat.MessageNodeScope +import com.getcode.ui.components.chat.UserAvatar +import com.getcode.ui.components.chat.messagecontents.utils.AlignmentRule +import com.getcode.ui.components.chat.messagecontents.utils.rememberAlignmentRule +import com.getcode.ui.components.chat.utils.MessageTip +import com.getcode.ui.components.text.markup.Markup +import com.getcode.ui.components.text.markup.MarkupTextHelper +import com.getcode.ui.utils.addIf +import com.getcode.ui.utils.dashedBorder +import com.getcode.ui.utils.rememberedLongClickable +import kotlinx.datetime.Instant + +@Composable +internal fun MessageNodeScope.MessageText( + modifier: Modifier = Modifier, + content: String, + shape: Shape = MessageNodeDefaults.DefaultShape, + options: MessageNodeOptions, + isFromSelf: Boolean, + isFromBlockedMember: Boolean, + tips: List, + date: Instant, + status: MessageStatus = MessageStatus.Unknown, + wasSentAsFullMember: Boolean, + showControls: () -> Unit, + showTipSelection: () -> Unit, + showTips: () -> Unit, +) { + val alignment = if (isFromSelf) Alignment.CenterEnd else Alignment.CenterStart + + BoxWithConstraints(modifier = modifier.fillMaxWidth(), contentAlignment = alignment) { + BoxWithConstraints( + modifier = Modifier + .sizeableWidth() + .addIf(wasSentAsFullMember) { + Modifier.background(color = color, shape = shape) + } + .addIf(!wasSentAsFullMember) { + Modifier.dashedBorder( + strokeWidth = CodeTheme.dimens.border, + dashWidth = 2.dp, + gapWidth = 2.dp, + dashColor = CodeTheme.colors.tertiary, + shape = shape + ) + } + .addIf(options.isInteractive) { + Modifier.rememberedLongClickable { + showControls() + } + } + .padding(CodeTheme.dimens.grid.x2) + ) contents@{ + val maxWidthPx = with(LocalDensity.current) { maxWidth.roundToPx() } + Column { + MessageContent( + maxWidth = maxWidthPx, + message = content, + date = date, + status = status, + isFromSelf = isFromSelf, + isFromBlockedMember = isFromBlockedMember, + options = options, + tips = tips, + openTipModal = showTips, + onLongPress = showControls, + onDoubleClick = showTipSelection + ) + } + } + } +} + +@Composable +internal fun MessageContent( + modifier: Modifier = Modifier, + minWidth: Int = 0, + maxWidth: Int, + message: String, + date: Instant, + status: MessageStatus, + isFromSelf: Boolean, + isFromBlockedMember: Boolean, + options: MessageNodeOptions, + tips: List, + openTipModal: () -> Unit, + onLongPress: () -> Unit = { }, + onDoubleClick: () -> Unit = { }, +) { + MessageContent( + modifier = modifier, + minWidth = minWidth, + maxWidth = maxWidth, + annotatedMessage = AnnotatedString(message), + date = date, + status = status, + isFromSelf = isFromSelf, + isFromBlockedMember = isFromBlockedMember, + tips = tips, + options = options, + openTipModal = openTipModal, + onLongPress = onLongPress, + onDoubleClick = onDoubleClick + ) +} + +@Composable +internal fun MessageContent( + modifier: Modifier = Modifier, + minWidth: Int = 0, + maxWidth: Int, + annotatedMessage: AnnotatedString, + date: Instant, + status: MessageStatus, + isFromSelf: Boolean, + isFromBlockedMember: Boolean, + options: MessageNodeOptions, + tips: List = emptyList(), + openTipModal: () -> Unit = { }, + onLongPress: () -> Unit = { }, + onDoubleClick: () -> Unit = { }, +) { + val openGraphParser = LocalOpenGraphParser.current + var linkImageUrl: String? by rememberSaveable(annotatedMessage) { mutableStateOf(null) } + + LaunchedEffect(annotatedMessage) { + if (linkImageUrl == null && options.linkImagePreviewEnabled) { + val link = Markup.Url().resolve(annotatedMessage.text).firstOrNull() + if (link != null) { + openGraphParser?.parse(link, object : OpenGraphCallback { + override fun onResponse(result: OpenGraphResult) { + linkImageUrl = result.image + } + + override fun onError(error: String) = Unit + }) + } + } + } + + val alignmentRule by rememberAlignmentRule( + contentTextStyle = options.contentStyle, + minWidth = minWidth, + maxWidth = maxWidth, + message = annotatedMessage, + date = date, + hasTips = tips.isNotEmpty(), + hasLink = linkImageUrl != null && options.linkImagePreviewEnabled + ) + + when (alignmentRule) { + AlignmentRule.Column -> { + Column( + modifier = modifier.width(IntrinsicSize.Max), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1) + ) { + MarkupTextHandler( + text = annotatedMessage, + options = options, + onLongPress = onLongPress, + isFromBlockedMember = isFromBlockedMember, + onDoubleClick = onDoubleClick, + ) + + if (options.linkImagePreviewEnabled) { + AnimatedVisibility(linkImageUrl != null) { + AsyncImage( + model = linkImageUrl, + contentDescription = null, + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2) + ) { + if (tips.isNotEmpty()) { + Tips(tips = tips, isMessageFromSelf = isFromSelf) { openTipModal() } + } + DateWithStatus( + modifier = Modifier + .weight(1f) + .align(Alignment.Bottom) + .pointerInput(Unit) { + detectTapGestures( + onLongPress = if (!options.isInteractive) null else { + { onLongPress() } + }, + onDoubleTap = { if (options.canTip) onDoubleClick() } + ) + }, + date = date, + status = status, + isFromSelf = isFromSelf, + showStatus = options.showStatus, + showTimestamp = options.showTimestamp, + ) + } + } + } + + AlignmentRule.ParagraphLastLine -> { + Column( + modifier = modifier.padding(CodeTheme.dimens.grid.x1), + ) { + MarkupTextHandler( + text = annotatedMessage, + options = options, + onLongPress = onLongPress, + isFromBlockedMember = isFromBlockedMember, + onDoubleClick = onDoubleClick, + ) + DateWithStatus( + modifier = Modifier + .align(Alignment.End) + .pointerInput(Unit) { + detectTapGestures( + onLongPress = if (!options.isInteractive) null else { + { onLongPress() } + }, + ) + }, + date = date, + status = status, + isFromSelf = isFromSelf, + showStatus = options.showStatus, + showTimestamp = options.showTimestamp, + ) + + AnimatedVisibility(linkImageUrl != null) { + AsyncImage( + model = linkImageUrl, + contentDescription = null, + ) + } + } + } + + AlignmentRule.SingleLineEnd -> { + Column(modifier = modifier) { + Row( + modifier = Modifier.width(IntrinsicSize.Max), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1) + ) { + MarkupTextHandler( + text = annotatedMessage, + options = options, + onLongPress = onLongPress, + isFromBlockedMember = isFromBlockedMember, + onDoubleClick = onDoubleClick, + ) + Spacer(Modifier.weight(1f)) + DateWithStatus( + modifier = Modifier + .padding(top = CodeTheme.dimens.grid.x1 + 2.dp) + .pointerInput(Unit) { + detectTapGestures( + onLongPress = if (!options.isInteractive) null else { + { onLongPress() } + }, + ) + }, + date = date, + status = status, + isFromSelf = isFromSelf, + showStatus = options.showStatus, + showTimestamp = options.showTimestamp, + ) + } + + AnimatedVisibility(linkImageUrl != null) { + AsyncImage( + model = linkImageUrl, + contentDescription = null, + ) + } + } + } + + else -> Unit + } +} + +@Composable +private fun MarkupTextHandler( + text: AnnotatedString, + options: MessageNodeOptions, + isFromBlockedMember: Boolean, + modifier: Modifier = Modifier, + onLongPress: () -> Unit = { }, + onDoubleClick: () -> Unit, +) { + when { + isFromBlockedMember -> { + Text( + modifier = modifier, + text = AnnotatedString.Builder().apply { + pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) + append(stringResource(R.string.title_blockedMessage)) + pop() + }.toAnnotatedString(), + style = options.contentStyle + ) + + } + + options.onMarkupClicked != null -> { + val handler = options.onMarkupClicked + val markupTextHelper = remember { MarkupTextHelper() } + val markups = options.markupsToResolve.map { Markup.create(it) } + + val annotatedString = markupTextHelper.annotate(text.text, markups) + + val handleTouchedContent = { offset: Int -> + annotatedString.getStringAnnotations( + tag = Markup.RoomNumber.TAG, + start = offset, + end = offset + ) + .firstOrNull()?.let { annotation -> + handler.invoke(Markup.RoomNumber(annotation.item.toLong())) + } + + annotatedString.getStringAnnotations( + tag = Markup.Url.TAG, + start = offset, + end = offset + ) + .firstOrNull()?.let { annotation -> + handler.invoke(Markup.Url(annotation.item)) + } + + annotatedString.getStringAnnotations( + tag = Markup.Phone.TAG, + start = offset, + end = offset + ).firstOrNull()?.let { annotation -> + handler.invoke(Markup.Phone(annotation.item)) + } + } + + var layoutResult by remember { mutableStateOf(null) } + + Text( + text = annotatedString, + style = options.contentStyle.copy(color = CodeTheme.colors.textMain), + modifier = modifier + .pointerInput(Unit) { + detectTapGestures( + onTap = { offset -> + layoutResult?.let { layoutResult -> + val position = layoutResult.getOffsetForPosition(offset) + handleTouchedContent(position) + } + }, + onDoubleTap = { _ -> if (options.canTip) onDoubleClick() }, + onLongPress = if (!options.isInteractive) null else { + { onLongPress() } + }, + ) + }, + onTextLayout = { layoutResult = it } + ) + } + + else -> { + Text(modifier = modifier, text = text, style = options.contentStyle) + } + } +} + +@Composable +private fun Tips( + tips: List, + isMessageFromSelf: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + if (tips.isNotEmpty()) { + val didUserTip = tips.any { it.tipper.isSelf } + val backgroundColor by animateColorAsState( + when { + isMessageFromSelf -> CodeTheme.colors.surface + didUserTip -> CodeTheme.colors.tertiary + else -> Color.Transparent + } + ) + + val borderColor by animateColorAsState( + when { + !didUserTip && !isMessageFromSelf -> CodeTheme.colors.tertiary + else -> Color.Transparent + } + ) + + val contentColor by animateColorAsState( + when { + isMessageFromSelf -> CodeTheme.colors.onBackground + didUserTip -> CodeTheme.colors.onBackground + else -> CodeTheme.colors.onBackground + } + ) + + val totalTips = tips.map { it.amount }.sum().formattedRaw() + + Row( + modifier = modifier + .clip(CircleShape) + .clickable { onClick() } + .background(backgroundColor, CircleShape) + .border(CodeTheme.dimens.border, borderColor, CircleShape) + .padding( + horizontal = CodeTheme.dimens.grid.x2, + vertical = CodeTheme.dimens.grid.x1, + ), + verticalAlignment = Alignment.CenterVertically, +// horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1) + ) { + Text( + text = stringResource(R.string.title_kinAmountWithLogo, totalTips), + color = contentColor, + style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.W700), + ) + +// TipperDisplay(tips, contentColor, modifier) + } + } +} + +@Composable +private fun TipperDisplay( + tips: List, + contentColor: Color, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy((-8).dp) + ) { + val imageModifier = Modifier + .size(CodeTheme.dimens.staticGrid.x4) + .clip(CircleShape) + .border(CodeTheme.dimens.border, contentColor, CircleShape) + + val tippers = remember(tips) { tips.map { it.tipper }.distinct() } + + tippers.take(3).fastForEachIndexed { index, tipper -> + UserAvatar( + modifier = imageModifier + .zIndex((tips.size - index).toFloat()), + data = tipper.profileImage.nullIfEmpty() ?: tipper.id + ) + } + } +} + +private fun String?.nullIfEmpty() = if (this?.isEmpty() == true) null else this \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/utils/ContentAlignment.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/utils/ContentAlignment.kt new file mode 100644 index 000000000..fee86bd28 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/utils/ContentAlignment.kt @@ -0,0 +1,88 @@ +package com.getcode.ui.components.chat.messagecontents.utils + +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.chat.messagecontents.DateWithStatusDefaults +import com.getcode.util.formatDateRelatively +import kotlinx.datetime.Instant +import kotlin.math.max + +internal sealed interface AlignmentRule { + data object ParagraphLastLine : AlignmentRule + data object Column : AlignmentRule + data object SingleLineEnd : AlignmentRule +} + +@Composable +internal fun rememberAlignmentRule( + contentTextStyle: TextStyle, + minWidth: Int = 0, + maxWidth: Int, + message: AnnotatedString, + date: Instant, + hasTips: Boolean = false, + hasLink: Boolean = false, +): State { + val density = LocalDensity.current + val dateTextStyle = DateWithStatusDefaults.DateTextStyle + val iconSizePx = with(density) { DateWithStatusDefaults.IconWidth.roundToPx() } + val spacingPx = with(density) { DateWithStatusDefaults.Spacing.roundToPx() } + val contentPaddingPx = with(density) { CodeTheme.dimens.grid.x2.roundToPx() } + + return remember(minWidth, maxWidth, message, date, hasLink, hasTips) { + mutableStateOf(null) + }.apply { + val textMeasurer = rememberTextMeasurer() + val dateStatusWidth = remember(message, date) { + val result = textMeasurer.measure( + text = date.formatDateRelatively(), + style = dateTextStyle, + maxLines = 1 + ) + + max(minWidth, result.size.width + spacingPx + iconSizePx) + } + + val bufferSize by remember(dateStatusWidth) { + derivedStateOf { + dateStatusWidth + spacingPx + contentPaddingPx * 2 + } + } + + if (value == null) { + Text( + modifier = Modifier.drawWithContent { }, + text = message, + style = contentTextStyle, + onTextLayout = { textLayoutResult -> + val lastLineNum = textLayoutResult.lineCount - 1 + val lineStart = textLayoutResult.getLineStart(lastLineNum) + val lineEnd = + textLayoutResult.getLineEnd(lastLineNum, visibleEnd = true) + val lineContent = message.substring(lineStart, lineEnd) + + val lineContentWidth = + textMeasurer.measure(lineContent, contentTextStyle).size.width + + value = when { + lineContentWidth + bufferSize > maxWidth || hasTips || hasLink -> AlignmentRule.Column + textLayoutResult.lineCount == 1 -> AlignmentRule.SingleLineEnd + else -> AlignmentRule.ParagraphLastLine + } + } + ) + } + } +} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/ChatItem.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/ChatItem.kt new file mode 100644 index 000000000..d10dc7f25 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/ChatItem.kt @@ -0,0 +1,73 @@ +package com.getcode.ui.components.chat.utils + +import androidx.compose.runtime.Stable +import com.getcode.model.ID +import com.getcode.model.KinAmount +import com.getcode.model.chat.ChatMessage +import com.getcode.model.chat.Deleter +import com.getcode.model.chat.MessageContent +import com.getcode.model.chat.MessageStatus +import com.getcode.model.chat.Sender +import com.getcode.model.uuid +import com.getcode.ui.components.chat.messagecontents.MessageControls +import com.getcode.util.formatDateRelatively +import com.getcode.utils.base58 +import kotlinx.datetime.Instant +import java.util.UUID + +data class ChatMessageIndice( + val message: ChatMessage, + val messageContent: MessageContent, +) + +data class ReplyMessageAnchor( + val id: ID, + val sender: Sender, + val message: MessageContent, + val isDeleted : Boolean = false, + val deletedBy: Deleter? = null, +) + +data class MessageTip(val amount: KinAmount, val tipper: Sender) + +@Stable +sealed class ChatItem(open val key: Any) { + @Stable + data class Message( + val chatMessageId: ID, + val message: MessageContent, + val sender: Sender, + val date: Instant, + val isDeleted: Boolean = false, + val deletedBy: Deleter? = null, + val status: MessageStatus, + val showStatus: Boolean = true, + val showTimestamp: Boolean = true, + val wasSentAsFullMember: Boolean = false, + val messageControls: MessageControls = MessageControls(), + val enableMarkup: Boolean = false, + val enableReply: Boolean = false, + val enableLinkImagePreview: Boolean = false, + val enableTipping : Boolean = false, + val originalMessage: ReplyMessageAnchor? = null, + val tips: List = emptyList(), + override val key: Any = chatMessageId + ) : ChatItem(key) { + val relativeDate: String = date.formatDateRelatively() + } + + sealed interface Separator + + @Stable + data class UnreadSeparator(val count: Int) : ChatItem("unread"), Separator + + @Stable + data class Date(val date: Instant) : ChatItem(date), Separator { + val dateString: String = date.formatDateRelatively() + + override val key: Any = date.toEpochMilliseconds() + } + + @Stable + data class Separators(val separators: List): ChatItem(separators.hashCode()) +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/chat/utils/HandleMessageChanges.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/HandleMessageChanges.kt similarity index 66% rename from app/src/main/java/com/getcode/ui/components/chat/utils/HandleMessageChanges.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/HandleMessageChanges.kt index 6dcfac8a5..8c4fa919b 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/utils/HandleMessageChanges.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/HandleMessageChanges.kt @@ -1,6 +1,5 @@ package com.getcode.ui.components.chat.utils -import android.os.Build import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -9,16 +8,17 @@ import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems -import com.getcode.ui.utils.isScrolledToTheBeginning +import com.getcode.model.chat.MessageStatus import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull @Composable -internal fun HandleMessageChanges( +fun HandleMessageChanges( listState: LazyListState, items: LazyPagingItems, onMessageDelivered: (ChatItem.Message) -> Unit, @@ -33,10 +33,10 @@ internal fun HandleMessageChanges( // handle incoming/outgoing messages - scroll to bottom to reset view in the following circumstances: // 1) New message is from self (e.g outgoing) // 2) New message is from participant and we are already at the bottom (to prevent rug pull) - LaunchedEffect(listState) { - snapshotFlow { items.itemSnapshotList } - .map { it.firstOrNull() } - .filterNotNull() + LaunchedEffect(listState, items) { + snapshotFlow { items.loadState } + .filter { it.refresh is LoadState.NotLoading } + .mapNotNull { items.itemSnapshotList.firstOrNull() } .filterIsInstance() .distinctUntilChangedBy { it.date } .collect { newMessage -> @@ -50,19 +50,11 @@ internal fun HandleMessageChanges( } else { listState.handleAndReplayAfter(300) { if (newMessage.date.toEpochMilliseconds() > lastMessageReceived) { - if (listState.isScrolledToTheBeginning()) { - // Android 10 we have to utilize a mimic for IME nested scrolling - // using the [LazyListState#isScrollInProgress] which animateScrollToItem triggers - // thus causing the IME to be dismissed when we trigger the sync. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - listState.scrollToItem(0) - } else { - listState.animateScrollToItem(0) + if (listState.canScrollBackward) { + if (newMessage.status == MessageStatus.Unknown) { + onMessageDelivered(newMessage) } } - - onMessageDelivered(newMessage) - lastMessageReceived = newMessage.date.toEpochMilliseconds() } lastMessageReceived = newMessage.date.toEpochMilliseconds() diff --git a/app/src/main/java/com/getcode/ui/components/chat/utils/LocalizedText.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/LocalizedText.kt similarity index 67% rename from app/src/main/java/com/getcode/ui/components/chat/utils/LocalizedText.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/LocalizedText.kt index e65bfe193..78d06ed2a 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/utils/LocalizedText.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/LocalizedText.kt @@ -2,47 +2,55 @@ package com.getcode.ui.components.chat.utils import android.annotation.SuppressLint import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.platform.LocalContext -import com.getcode.BuildConfig -import com.getcode.LocalCurrencyUtils -import com.getcode.R -import com.getcode.manager.SessionManager +import com.getcode.ui.components.R import com.getcode.model.Currency -import com.getcode.model.Domain import com.getcode.model.GenericAmount import com.getcode.model.chat.MessageContent import com.getcode.model.chat.Title import com.getcode.model.chat.Verb -import com.getcode.util.CurrencyUtils -import com.getcode.util.Kin -import com.getcode.util.formatted +import com.getcode.utils.Kin +import com.getcode.extensions.formatted import com.getcode.util.resources.ResourceHelper import com.getcode.util.resources.ResourceType import com.getcode.utils.FormatUtils -import timber.log.Timber +import com.getcode.utils.LocalCurrencyUtils import java.util.Locale -internal fun MessageContent.localizedText( - title: String, +val LocalLocalizeCurrencyFormatting = staticCompositionLocalOf { true } + +fun MessageContent.localizedText( resources: ResourceHelper, - currencyUtils: CurrencyUtils, + localizeCurrency: Boolean = true, + currencyUtils: com.getcode.utils.CurrencyUtils, ): String { return when (val content = this) { is MessageContent.Exchange -> { val amount = when (val kinAmount = content.amount) { is GenericAmount.Exact -> { - Timber.d("exact") - val currency = currencyUtils.getCurrency(kinAmount.currencyCode.name) + val currency = if (localizeCurrency) { + currencyUtils.getCurrency(kinAmount.currencyCode.name) + } else { + null + } + + val amount = if (localizeCurrency) { + kinAmount.amount.fiat + } else { + kinAmount.amount.kin.toKinValueDouble() + } + kinAmount.amount.formatted( resources = resources, + amount = amount, currency = currency ?: Currency.Kin ) } is GenericAmount.Partial -> { - Timber.d("partial") FormatUtils.formatCurrency(kinAmount.fiat.amount, kinAmount.currencyCode).let { - "$it ${resources.getString(R.string.core_ofKin)}" + "$it ${resources.getOfKinSuffix()}" } } } @@ -80,44 +88,71 @@ internal fun MessageContent.localizedText( } is MessageContent.SodiumBox -> { - val organizer = SessionManager.getOrganizer() ?: return "" - val domain = Domain.from(title) ?: return "" - val relationship = organizer.relationshipFor(domain) ?: return "" - val decrypted = content.data.decryptMessageUsingNaClBox( - keyPair = relationship.getCluster().authority.keyPair - ) ?: return "" - - decrypted + // TODO: +// val organizer = SessionManager.getOrganizer() ?: return "" +// val domain = com.getcode.crypt.Domain.from(title) ?: return "" +// val relationship = organizer.relationshipFor(domain) ?: return "" +// val decrypted = content.data.decryptMessageUsingNaClBox( +// keyPair = relationship.getCluster().authority.keyPair +// ) ?: return "" +// +// decrypted + return "" } is MessageContent.Decrypted -> content.data - is MessageContent.IdentityRevealed -> { - val resId = if (content.isFromSelf) R.string.title_chat_announcement_identityRevealed - else R.string.title_chat_announcement_identityRevealedToYou - - resources.getString(resId, content.identity.username) - } is MessageContent.RawText -> content.value - is MessageContent.ThankYou -> { + is MessageContent.ActionableAnnouncement -> { + val resId = resources.getIdentifier( + content.keyOrText.replace(".", "_"), + ResourceType.String, + ).let { if (it == 0) null else it } + + resId?.let { resources.getString(it) } ?: content.keyOrText + } + is MessageContent.Announcement -> { val resId = if (content.isFromSelf) R.string.title_chat_announcement_thanksSent else R.string.title_chat_announcement_thanksReceived resources.getString(resId, "some username") } + + is MessageContent.Reaction -> content.emoji + is MessageContent.Reply -> content.text + is MessageContent.DeletedMessage, + is MessageContent.MessageTip, + is MessageContent.MessageInReview, + is MessageContent.Unknown -> "" + + } } -internal val MessageContent.localizedText: String +val MessageContent.localizedText: String @Composable get() { val context = LocalContext.current return when (val content = this) { is MessageContent.Exchange -> { val amount = when (val kinAmount = content.amount) { is GenericAmount.Exact -> { - val currency = + val localizeCurrencyFormatting = LocalLocalizeCurrencyFormatting.current + val currency = if (localizeCurrencyFormatting) { LocalCurrencyUtils.current?.getCurrency(kinAmount.currencyCode.name) - kinAmount.amount.formatted(currency = currency ?: Currency.Kin) + } else { + null + } + + val amount = if (localizeCurrencyFormatting) { + kinAmount.amount.fiat + } else { + kinAmount.amount.kin.toKinValueDouble() + } + + kinAmount.amount.formatted( + amount = amount, + currency = currency ?: Currency.Kin + ) } is GenericAmount.Partial -> { @@ -157,7 +192,7 @@ internal val MessageContent.localizedText: String val resId = resources.getIdentifier( content.value.replace(".", "_"), "string", - BuildConfig.APPLICATION_ID + context.packageName ).let { if (it == 0) null else it } resId?.let { getString(it) } ?: content.value @@ -166,23 +201,25 @@ internal val MessageContent.localizedText: String is MessageContent.SodiumBox -> "" is MessageContent.Decrypted -> content.data - is MessageContent.IdentityRevealed -> { - with(LocalContext.current) { - val resId = if (content.isFromSelf) R.string.title_chat_announcement_identityRevealed - else R.string.title_chat_announcement_identityRevealedToYou - - getString(resId, content.identity.username) - } - } is MessageContent.RawText -> content.value - is MessageContent.ThankYou -> { + is MessageContent.Announcement -> content.value + is MessageContent.ActionableAnnouncement -> { with(LocalContext.current) { - val resId = if (content.isFromSelf) R.string.title_chat_announcement_thanksSent - else R.string.title_chat_announcement_thanksReceived + val resId = resources.getIdentifier( + content.keyOrText.replace(".", "_"), + "string", + context.packageName + ).let { if (it == 0) null else it } - getString(resId, "some username") + resId?.let { getString(it) } ?: content.keyOrText } } + is MessageContent.Reaction -> content.emoji + is MessageContent.Reply -> content.text + is MessageContent.DeletedMessage, + is MessageContent.MessageTip, + is MessageContent.MessageInReview, + is MessageContent.Unknown -> "" } } @@ -246,7 +283,7 @@ val Verb.localizedText: String resources.getIdentifier( "subtitle_you${this@localizedText.toString().capitalize(Locale.ENGLISH)}", "string", - BuildConfig.APPLICATION_ID + packageName ).let { if (it == 0) null else it } } @@ -254,7 +291,7 @@ val Verb.localizedText: String resources.getIdentifier( "subtitle_someoneTippedYou", "string", - BuildConfig.APPLICATION_ID + packageName ).let { if (it == 0) null else it } } @@ -262,7 +299,7 @@ val Verb.localizedText: String resources.getIdentifier( "subtitle_youTipped", "string", - BuildConfig.APPLICATION_ID + packageName ).let { if (it == 0) null else it } } @@ -270,7 +307,7 @@ val Verb.localizedText: String resources.getIdentifier( "subtitle_was${this@localizedText.toString().capitalize(Locale.ENGLISH)}ToYou", "string", - BuildConfig.APPLICATION_ID + packageName ).let { if (it == 0) null else it } } @@ -293,7 +330,7 @@ val Title?.localized: String val resId = resources.getIdentifier( t.value, "string", - BuildConfig.APPLICATION_ID + packageName ).let { if (it == 0) null else it } resId?.let { getString(it) } ?: t.value diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/contextmenu/ContextSheet.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/contextmenu/ContextSheet.kt new file mode 100644 index 000000000..8c5081ff2 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/contextmenu/ContextSheet.kt @@ -0,0 +1,17 @@ +package com.getcode.ui.components.contextmenu + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter + + +interface ContextMenuAction { + val onSelect: () -> Unit + + @get:Composable + val title: String + + @get:Composable + val painter: Painter + val isDestructive: Boolean + val delayUponSelection: Boolean +} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/picker/Picker.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/picker/Picker.kt new file mode 100644 index 000000000..19fed7f40 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/picker/Picker.kt @@ -0,0 +1,155 @@ +package com.getcode.ui.components.picker + +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +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.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme +import com.getcode.ui.utils.fadingEdge +import com.getcode.ui.utils.measured +import com.getcode.util.vibration.LocalVibrator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds + +@Composable +fun rememberPickerState( + items: List, + prefix: String = "", + labelForItem: (T) -> String = { item -> item.toString() } +): PickerState { + + return remember(items, prefix) { + PickerState(items, labelForItem, prefix) + } +} + +@Immutable +data class PickerState( + val items: List, + val labelForItem: (T) -> String = { item -> item.toString() }, + val prefix: String = "", +) { + var selectedItem by mutableStateOf(null) + internal set +} + +@Composable +fun Picker( + state: PickerState, + modifier: Modifier = Modifier, + enabled: Boolean = true, + visibleItemsCount: Int = 3, + textModifier: Modifier = Modifier, + textStyle: TextStyle = LocalTextStyle.current, +) { + val items = remember(state.items) { + val labels = state.items.map { state.labelForItem(it) } + listOf("") + labels + listOf("") + } + + fun getItem(index: Int): String = items[index] + + val listState = rememberLazyListState(initialFirstVisibleItemIndex = 0) + val flingBehavior = rememberSnapFlingBehavior(lazyListState = listState) + + var itemHeight by remember { mutableStateOf(0.dp) } + + val vibrator = LocalVibrator.current + + LaunchedEffect(items) { + snapshotFlow { listState.firstVisibleItemIndex to listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } + .map { (first, last) -> + val index = ((first + (last ?: first)) / 2).coerceIn(1..items.lastIndex) + getItem(index) + } + .distinctUntilChanged() + .onEach { vibrator.tick() } + .debounce(300.milliseconds) + .onEach { item -> + withContext(Dispatchers.Main) { + state.selectedItem = state.items.find { state.labelForItem(it) == item } + } + }.launchIn(this) + } + + val textMeasurer = rememberTextMeasurer() + val buffer = with(LocalDensity.current) { CodeTheme.dimens.grid.x4.roundToPx() } + val itemWidthPixels = remember(items) { + items.maxOfOrNull { + textMeasurer.measure(text = it, style = textStyle, maxLines = 1).size.width + buffer + } + } + + Box(modifier = modifier) { + LazyColumn( + state = listState, + flingBehavior = flingBehavior, + userScrollEnabled = enabled, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .height(itemHeight * visibleItemsCount) + .fadingEdge() + ) { + itemsIndexed(items) { _, item -> + Text( + text = item, + maxLines = 1, + style = textStyle, + modifier = Modifier + .measured { itemHeight = it.height } + .then(textModifier) + ) + } + } + // Fixed prefix + if (itemWidthPixels != null) { + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center), + ) { + Text( + text = state.prefix, + style = textStyle.copy(Color.White), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .align(Alignment.Center) + .padding(end = pixelsToDp(itemWidthPixels + buffer)) + ) + } + } + } +} + +@Composable +private fun pixelsToDp(pixels: Int) = with(LocalDensity.current) { pixels.toDp() } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/scanner/views/RestrictionBlockingView.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/restrictions/RestrictionBlockingView.kt similarity index 91% rename from app/src/main/java/com/getcode/view/main/scanner/views/RestrictionBlockingView.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/restrictions/RestrictionBlockingView.kt index d4152c557..f0893cc26 100644 --- a/app/src/main/java/com/getcode/view/main/scanner/views/RestrictionBlockingView.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/restrictions/RestrictionBlockingView.kt @@ -1,4 +1,4 @@ -package com.getcode.view.main.scanner.views +package com.getcode.ui.components.restrictions import android.app.Activity import android.content.ActivityNotFoundException @@ -18,16 +18,19 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp -import com.getcode.R -import com.getcode.RestrictionType -import com.getcode.theme.Brand import com.getcode.theme.CodeTheme -import com.getcode.theme.White -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.components.R +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton + +enum class RestrictionType { + ACCESS_EXPIRED, + FORCE_UPGRADE, + TIMELOCK_UNLOCKED +} @Composable -fun HomeRestricted( +fun ContentRestrictedView( restrictionType: RestrictionType, onLogoutClick: (Activity) -> Unit = {} ) { @@ -61,7 +64,7 @@ fun HomeRestricted( Box( modifier = Modifier .fillMaxSize() - .background(Brand) + .background(CodeTheme.colors.background) .padding(horizontal = CodeTheme.dimens.grid.x6) ) { Column( @@ -74,7 +77,7 @@ fun HomeRestricted( .fillMaxWidth() .padding(horizontal = CodeTheme.dimens.grid.x5) .padding(bottom = CodeTheme.dimens.grid.x5), - color = White, + color = CodeTheme.colors.onBackground, text = titleText, style = CodeTheme.typography.textLarge.copy(textAlign = TextAlign.Center) ) diff --git a/app/src/main/java/com/getcode/view/main/giveKin/AmountArea.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/text/AmountArea.kt similarity index 92% rename from app/src/main/java/com/getcode/view/main/giveKin/AmountArea.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/text/AmountArea.kt index 84eb855b8..9584cdc3e 100644 --- a/app/src/main/java/com/getcode/view/main/giveKin/AmountArea.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/text/AmountArea.kt @@ -1,4 +1,4 @@ -package com.getcode.view.main.giveKin +package com.getcode.ui.components.text import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.Image @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier @@ -19,16 +18,14 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import com.getcode.LocalNetworkObserver -import com.getcode.R -import com.getcode.theme.Alert -import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme import com.getcode.theme.bolded +import com.getcode.ui.components.ConnectionStatus +import com.getcode.ui.components.R import com.getcode.ui.utils.rememberedClickable +import com.getcode.utils.network.LocalNetworkObserver import com.getcode.utils.network.NetworkState -import com.getcode.view.main.connectivity.ConnectionStatus -import com.getcode.view.main.connectivity.NetworkStateProvider +import com.getcode.utils.network.connectivity.NetworkStateProvider @OptIn(ExperimentalAnimationApi::class) @Composable @@ -37,11 +34,12 @@ fun AmountArea( amountPrefix: String? = null, amountText: String, amountSuffix: String? = null, + placeholder: String = "0", captionText: String? = null, isAltCaption: Boolean = false, isAltCaptionKinIcon: Boolean = true, altCaptionColor: Color? = null, - currencyResId: Int?, + currencyResId: Int? = null, isClickable: Boolean = true, isLoading: Boolean = false, isAnimated: Boolean = false, @@ -70,6 +68,7 @@ fun AmountArea( AmountTextAnimated( uiModel = uiModel, currencyResId = currencyResId, + placeholder = placeholder, amountPrefix = amountPrefix.orEmpty(), amountSuffix = amountSuffix.orEmpty(), textStyle = textStyle, @@ -81,8 +80,8 @@ fun AmountArea( KinValueHint( modifier = Modifier.align(CenterHorizontally), showIcon = isAltCaption && isAltCaptionKinIcon, - iconColor = altCaptionColor ?: BrandLight, - captionColor = if (isAltCaption) (altCaptionColor ?: Alert) else BrandLight, + iconColor = altCaptionColor ?: CodeTheme.colors.brandLight, + captionColor = if (isAltCaption) (altCaptionColor ?: CodeTheme.colors.errorText) else CodeTheme.colors.brandLight, captionText = captionText, networkState = networkState ) diff --git a/app/src/main/java/com/getcode/view/main/giveKin/AmountText.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/text/AmountText.kt similarity index 97% rename from app/src/main/java/com/getcode/view/main/giveKin/AmountText.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/text/AmountText.kt index 1f49e45b1..0b3b4af2f 100644 --- a/app/src/main/java/com/getcode/view/main/giveKin/AmountText.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/text/AmountText.kt @@ -1,4 +1,4 @@ -package com.getcode.view.main.giveKin +package com.getcode.ui.components.text import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement @@ -17,7 +17,6 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier @@ -28,10 +27,10 @@ import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.TextUnit -import com.getcode.R import com.getcode.theme.CodeTheme import com.getcode.theme.White import com.getcode.theme.bolded +import com.getcode.ui.components.R object AmountSizeStore { diff --git a/app/src/main/java/com/getcode/view/main/giveKin/AmountTextAnimated.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/text/AmountTextAnimated.kt similarity index 81% rename from app/src/main/java/com/getcode/view/main/giveKin/AmountTextAnimated.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/text/AmountTextAnimated.kt index ed2862c0c..002f742f3 100644 --- a/app/src/main/java/com/getcode/view/main/giveKin/AmountTextAnimated.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/text/AmountTextAnimated.kt @@ -1,4 +1,4 @@ -package com.getcode.view.main.giveKin +package com.getcode.ui.components.text import androidx.compose.animation.* import androidx.compose.animation.core.* @@ -11,7 +11,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource @@ -19,11 +18,10 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* -import com.getcode.R import com.getcode.theme.CodeTheme -import com.getcode.util.NumberInputHelper -import com.getcode.util.NumberInputHelper.Companion.DECIMAL_SEPARATOR -import com.getcode.util.NumberInputHelper.Companion.GROUPING_SEPARATOR +import com.getcode.ui.components.R +import com.getcode.ui.components.text.NumberInputHelper.Companion.DECIMAL_SEPARATOR +import com.getcode.ui.components.text.NumberInputHelper.Companion.GROUPING_SEPARATOR import java.util.* import kotlin.collections.HashMap import kotlin.concurrent.schedule @@ -100,6 +98,7 @@ fun Digit( @Composable fun AnimatedPlaceholderDigit( text: String, + placeholder: String, digitVisible: Boolean, placeholderVisible: Boolean, fontSize: TextUnit, @@ -108,25 +107,27 @@ fun AnimatedPlaceholderDigit( placeholderExit: ExitTransition? = null, placeholderColor: Color = Color.White.copy(alpha = .2f)) { - Box { - Row { - Digit( - isVisible = placeholderVisible, - text = "0", - fontSize = fontSize, - density = density, - enter = placeholderEnter, - exit = placeholderExit, - color = placeholderColor - ) - } - Row { - Digit( - isVisible = digitVisible, - text = text, - fontSize = fontSize, - density = density, - ) + if (placeholderVisible || digitVisible) { + Box { + Row { + Digit( + isVisible = placeholderVisible, + text = placeholder, + fontSize = fontSize, + density = density, + enter = placeholderEnter, + exit = placeholderExit, + color = placeholderColor + ) + } + Row { + Digit( + isVisible = digitVisible, + text = text, + fontSize = fontSize, + density = density, + ) + } } } } @@ -138,7 +139,10 @@ internal fun AmountTextAnimated( currencyResId: Int?, amountPrefix: String, amountSuffix: String, + placeholder: String = "0", uiModel: AmountAnimatedInputUiModel?, + maxDigits: Int = 15, + totalDecimals: Int = 2, textStyle: TextStyle, isClickable: Boolean, ) { @@ -148,16 +152,12 @@ internal fun AmountTextAnimated( val staticX8 = CodeTheme.dimens.staticGrid.x8 - //Maximum possible values - val maxDigits = 10 - val totalDecimal = 2 - //Visibility states var decimalPointVisibility by remember { mutableStateOf(false) } var zeroVisibility by remember { mutableStateOf(true) } val digitVisibility = remember { mutableStateListOf(*Array(maxDigits) { it == 0 }) } - val digitDecimalVisibility = remember { mutableStateListOf(*Array(totalDecimal) { false }) } - val digitDecimalZeroVisibility = remember { mutableStateListOf(*Array(totalDecimal) { false }) } + val digitDecimalVisibility = remember { mutableStateListOf(*Array(totalDecimals) { false }) } + val digitDecimalZeroVisibility = remember { mutableStateListOf(*Array(totalDecimals) { false }) } var firstDigit by remember { mutableStateOf("") } //Font states @@ -172,7 +172,7 @@ internal fun AmountTextAnimated( val length1 = amountSplit[0].length val length2 = if (amountSplit.size > 1) amountSplit[1].length else 0 val isDecimal = amountSplit.size > 1 - val isZero = amountSplit[0] == "0" + val isZero = amountSplit[0] == "0" || amountSplit[0].isEmpty() if (amountSplit.firstOrNull() != null && !isZero) { firstDigit = uiModel.amountData.amount.first().toString() @@ -339,7 +339,8 @@ internal fun AmountTextAnimated( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - Spacer(modifier = Modifier.width(CodeTheme.dimens.staticGrid.x1)) + val prefixPadding = if (amountSuffix.isEmpty()) CodeTheme.dimens.staticGrid.x2 else CodeTheme.dimens.staticGrid.x1 + Spacer(modifier = Modifier.width(prefixPadding)) if (currencyResId != null && currencyResId > 0) { Image( @@ -371,6 +372,7 @@ internal fun AmountTextAnimated( AnimatedPlaceholderDigit( text = firstDigit, digitVisible = digitVisibility[0], + placeholder = placeholder, placeholderVisible = zeroVisibility, fontSize = textSize, density = density, @@ -379,51 +381,42 @@ internal fun AmountTextAnimated( placeholderColor = Color.White ) - Row( - modifier = Modifier - /*.animateContentSize( - animationSpec = tween( - durationMillis = 100, - easing = LinearOutSlowInEasing, - ), - )*/ - ) { - for (i in 1 until maxDigits) { - Digit( - getComma(i), - GROUPING_SEPARATOR.toString(), - textSize, - density, - enter = expandHorizontally() + fadeIn(), - exit = shrinkHorizontally() + fadeOut(), - ) - Digit( - digitVisibility[i], - getValue(0, i) ?: getLastValue(0, i) ?: "", - textSize, - density - ) - } - + for (i in 1 until maxDigits) { + Digit( + getComma(i), + GROUPING_SEPARATOR.toString(), + textSize, + density, + enter = expandHorizontally() + fadeIn(), + exit = shrinkHorizontally() + fadeOut(), + ) Digit( - isVisible = decimalPointVisibility, - text = DECIMAL_SEPARATOR.toString(), + digitVisibility[i], + getValue(0, i) ?: getLastValue(0, i) ?: "", + textSize, + density + ) + } + + Digit( + isVisible = decimalPointVisibility, + text = DECIMAL_SEPARATOR.toString(), + fontSize = textSize, + density = density, + enter = decimalEnter, + ) + + for (i in 0 until totalDecimals) { + AnimatedPlaceholderDigit( + text = getValue(1, i) ?: getLastValue(1, i) ?: "0", + digitVisible = digitDecimalVisibility[i], + placeholderVisible = digitDecimalZeroVisibility[i], + placeholder = "0", fontSize = textSize, density = density, - enter = decimalEnter, + placeholderEnter = decimalZeroEnter, + placeholderExit = decimalZeroExit, ) - - for (i in 0 until totalDecimal) { - AnimatedPlaceholderDigit( - text = getValue(1, i) ?: getLastValue(1, i) ?: "0", - digitVisible = digitDecimalVisibility[i], - placeholderVisible = digitDecimalZeroVisibility[i], - fontSize = textSize, - density = density, - placeholderEnter = decimalZeroEnter, - placeholderExit = decimalZeroExit, - ) - } } Text( diff --git a/app/src/main/java/com/getcode/util/NumberInputHelper.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/text/NumberInputHelper.kt similarity index 79% rename from app/src/main/java/com/getcode/util/NumberInputHelper.kt rename to ui/components/src/main/kotlin/com/getcode/ui/components/text/NumberInputHelper.kt index b9c80063c..336cf342c 100644 --- a/app/src/main/java/com/getcode/util/NumberInputHelper.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/text/NumberInputHelper.kt @@ -1,11 +1,9 @@ -package com.getcode.util +package com.getcode.ui.components.text import java.text.DecimalFormatSymbols import java.text.NumberFormat import java.util.* -import kotlin.math.floor import kotlin.math.min -import kotlin.math.roundToInt class NumberInputHelper { @@ -56,8 +54,8 @@ class NumberInputHelper { return formatAmount(amount) } - fun getFormattedStringForAnimation(): AmountAnimatedData { - return formatAmountForAnimation(amount) + fun getFormattedStringForAnimation(includeCommas: Boolean = true): AmountAnimatedData { + return formatAmountForAnimation(amount, includeCommas) } private fun getValueWithoutTrailingPeriod(v: String): String { @@ -78,7 +76,7 @@ class NumberInputHelper { } } - private fun formatAmountForAnimation(amount: Double): AmountAnimatedData { + private fun formatAmountForAnimation(amount: Double, includeCommas: Boolean = true): AmountAnimatedData { val isContainsDot = amountText.contains(DECIMAL_SEPARATOR) val decimalLength = min( amountText.split(DECIMAL_SEPARATOR).getOrNull(1)?.length ?: 0, 2 @@ -87,20 +85,21 @@ class NumberInputHelper { 2 -> String.format("%,.2f", amount) 1 -> String.format("%,.1f", amount) else -> String.format("%,.0f", amount).let { if (isContainsDot) "$it$DECIMAL_SEPARATOR" else it } - } - .let { - val amountWithoutCommas = it.replace(GROUPING_SEPARATOR.toString(), "") - val lengthWithoutCommas = amountWithoutCommas.length - val commaVisibility = mutableListOf(*Array(lengthWithoutCommas) { false }) + }.let { + val amountWithoutCommas = it.replace(GROUPING_SEPARATOR.toString(), "") + val lengthWithoutCommas = amountWithoutCommas.length + val commaVisibility = mutableListOf(*Array(lengthWithoutCommas) { false }) + if (includeCommas) { var offset = 0 it.forEachIndexed { index, c -> val isComma = c == GROUPING_SEPARATOR - commaVisibility[index-offset] = isComma || commaVisibility[index-offset] + commaVisibility[index - offset] = isComma || commaVisibility[index - offset] if (isComma) offset++ } - - AmountAnimatedData(amountWithoutCommas, commaVisibility) } + + AmountAnimatedData(amountWithoutCommas, commaVisibility) + } } companion object { diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/text/markup/Markup.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/text/markup/Markup.kt new file mode 100644 index 000000000..bddb3d246 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/text/markup/Markup.kt @@ -0,0 +1,153 @@ +package com.getcode.ui.components.text.markup + +import android.net.Uri +import android.util.Patterns +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.core.net.toUri +import com.getcode.ui.components.R +import com.getcode.ui.components.text.markup.Markup.RoomNumber.Companion.regex +import kotlin.jvm.optionals.toList +import kotlin.reflect.KClass + +sealed interface Markup { + @Composable + fun process(text: String): AnnotatedString + fun resolve(text: String): List + + sealed interface Interactive + + companion object { + fun create(type: KClass): Markup { + return when (type) { + RoomNumber::class -> RoomNumber(0) + Url::class -> Url("") + Phone::class -> Phone("") + else -> throw IllegalArgumentException("Unknown Markup type") + } + } + } + + data class RoomNumber(val number: Long): Markup, Interactive { + companion object { + const val TAG: String = "ROOM_NUMBER" + val regex = Regex("#\\d+") + } + + override fun resolve(text: String): List { + return regex.findAll(text).map { it.value }.toList() + } + + @Composable + override fun process(text: String): AnnotatedString { + + return buildAnnotatedString { + var lastIndex = 0 + regex.findAll(text).forEach { matchResult -> + val start = matchResult.range.first + val end = matchResult.range.last + 1 + + append(text.substring(lastIndex, start)) + + val number = matchResult.value.removePrefix("#") + pushStringAnnotation(tag = TAG, annotation = number) + withStyle(style = SpanStyle(textDecoration = TextDecoration.Underline)) { + append(matchResult.value) + } + pop() + + lastIndex = end + } + append(text.substring(lastIndex)) + } + } + } + + data class Url(val link: String = ""): Markup, Interactive { + companion object { + const val TAG: String = "URL" + } + + override fun resolve(text: String): List { + val matcher = Patterns.WEB_URL.matcher(text) + val results = mutableListOf() + while (matcher.find()) { + results.add(matcher.group()) + } + return results + } + + @Composable + override fun process(text: String): AnnotatedString { + val matcher = Patterns.WEB_URL.matcher(text) + return buildAnnotatedString { + var lastIndex = 0 + while (matcher.find()) { + val start = matcher.start() + val end = matcher.end() + + append(text.substring(lastIndex, start)) + + val rawUrl = matcher.group() + val uri = rawUrl.toUri() + val resolvedUrl = if (uri.scheme == null) { + Uri.Builder() + .scheme("https") + .authority(uri.path) + .build().toString() + } else { + rawUrl + } + + pushStringAnnotation(tag = TAG, annotation = resolvedUrl) + withStyle(style = SpanStyle(textDecoration = TextDecoration.Underline)) { + append(rawUrl) + } + pop() + + lastIndex = end + } + append(text.substring(lastIndex)) + } + } + } + data class Phone(val phoneNumber: String): Markup, Interactive { + companion object { + const val TAG: String = "PHONE_NUMBER" + val regex = Regex("^\\+?[0-9]{1,3}[ \\-.]?\\(?[0-9]{1,4}\\)?[ \\-.]?[0-9]{3,}([ \\-.]?[0-9]{2,})*$") + } + + override fun resolve(text: String): List { + return regex.findAll(text).map { it.value }.toList() + } + + @Composable + override fun process(text: String): AnnotatedString { + return buildAnnotatedString { + var lastIndex = 0 + regex.findAll(text).forEach { matchResult -> + val start = matchResult.range.first + val end = matchResult.range.last + 1 + + append(text.substring(lastIndex, start)) + + val phone = matchResult.value + pushStringAnnotation(tag = TAG, annotation = phone) + withStyle(style = SpanStyle(textDecoration = TextDecoration.Underline)) { + append(phone) + } + pop() + + lastIndex = end + } + append(text.substring(lastIndex)) + } + } + } +} diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/text/markup/MarkupTextHelper.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/text/markup/MarkupTextHelper.kt new file mode 100644 index 000000000..9a9d6d6c0 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/text/markup/MarkupTextHelper.kt @@ -0,0 +1,34 @@ +package com.getcode.ui.components.text.markup + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString + +class MarkupTextHelper { + + @Composable + fun annotate(text: String, markups: List): AnnotatedString { + return buildAnnotatedString { + append(text) + + markups.forEach { markup -> + val annotatedText = markup.process(text) + annotatedText.spanStyles.forEach { spanStyle -> + addStyle( + style = spanStyle.item, + start = spanStyle.start, + end = spanStyle.end + ) + } + annotatedText.getStringAnnotations(0, text.length).forEach { annotation -> + addStringAnnotation( + tag = annotation.tag, + annotation = annotation.item, + start = annotation.start, + end = annotation.end + ) + } + } + } + } +} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/decor/ScrimSupport.kt b/ui/components/src/main/kotlin/com/getcode/ui/decor/ScrimSupport.kt new file mode 100644 index 000000000..695fc302b --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/decor/ScrimSupport.kt @@ -0,0 +1,71 @@ +package com.getcode.ui.decor + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +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.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import com.getcode.theme.Black40 +import com.getcode.ui.utils.rememberedClickable + +sealed interface ScrimState { + data object Visible: ScrimState + data object Hidden: ScrimState + + val isVisible: Boolean + get() = this is Visible +} + +sealed interface ScrimStateChange { + data object Show: ScrimStateChange + data object Hide: ScrimStateChange +} + +val LocalScrim = staticCompositionLocalOf { ScrimState.Hidden } +val LocalScrimHandler = staticCompositionLocalOf<(ScrimStateChange) -> Unit> { { } } + +@Composable +fun ScrimSupport(screenContent: @Composable () -> Unit) { + Box(modifier = Modifier.fillMaxSize()) { + var scrimState by remember { mutableStateOf(ScrimState.Hidden) } + val handleStateChange = { event: ScrimStateChange -> + scrimState = when (event) { + ScrimStateChange.Hide -> ScrimState.Hidden + is ScrimStateChange.Show -> ScrimState.Visible + } + } + + CompositionLocalProvider( + LocalScrim provides scrimState, + LocalScrimHandler provides handleStateChange + ) { + screenContent() + } + + val scrimAlpha by animateFloatAsState(if (scrimState.isVisible) 1f else 0f, label = "scrim visibility") + + when (scrimState) { + is ScrimState.Visible -> { + Box( + modifier = Modifier + .fillMaxSize() + .alpha(scrimAlpha) + .background(Black40) + .rememberedClickable(indication = null, + interactionSource = remember { MutableInteractionSource() }) {} + ) + } + + ScrimState.Hidden -> Unit + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/modals/TipConfirmation.kt b/ui/components/src/main/kotlin/com/getcode/ui/modals/TipConfirmation.kt similarity index 91% rename from app/src/main/java/com/getcode/ui/modals/TipConfirmation.kt rename to ui/components/src/main/kotlin/com/getcode/ui/modals/TipConfirmation.kt index 6d160528b..1631f5e8d 100644 --- a/app/src/main/java/com/getcode/ui/modals/TipConfirmation.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/modals/TipConfirmation.kt @@ -21,24 +21,25 @@ import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import coil3.request.error -import com.getcode.R import com.getcode.model.TwitterUser import com.getcode.models.ConfirmationState import com.getcode.models.SocialUserPaymentConfirmation import com.getcode.theme.CodeTheme -import com.getcode.theme.White10 import com.getcode.theme.bolded -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton import com.getcode.ui.components.Modal +import com.getcode.ui.components.PriceWithFlag +import com.getcode.ui.components.R import com.getcode.ui.components.SlideToConfirm +import com.getcode.ui.components.SlideToConfirmDefaults import com.getcode.ui.components.TwitterUsernameDisplay -import com.getcode.view.main.scanner.components.PriceWithFlag @Composable -internal fun TipConfirmation( +fun TipConfirmation( modifier: Modifier = Modifier, confirmation: SocialUserPaymentConfirmation?, + trackColor: Color = SlideToConfirmDefaults.ThemedColor, onSend: () -> Unit, onCancel: () -> Unit, ) { @@ -79,7 +80,7 @@ internal fun TipConfirmation( Divider( modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x8), - color = White10, + color = CodeTheme.colors.divider, ) val amount by remember(confirmation?.amount) { @@ -103,6 +104,7 @@ internal fun TipConfirmation( SlideToConfirm( isLoading = isSending, isSuccess = state is ConfirmationState.Sent, + trackColor = trackColor, onConfirm = { onSend() }, label = stringResource(R.string.action_swipeToTip) ) diff --git a/common/components/src/main/kotlin/com/getcode/ui/components/CodeBillSwipeToDismiss.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeBillSwipeToDismiss.kt similarity index 99% rename from common/components/src/main/kotlin/com/getcode/ui/components/CodeBillSwipeToDismiss.kt rename to ui/components/src/main/kotlin/com/getcode/ui/theme/CodeBillSwipeToDismiss.kt index ab0dc3ba1..7c5736ba0 100644 --- a/common/components/src/main/kotlin/com/getcode/ui/components/CodeBillSwipeToDismiss.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeBillSwipeToDismiss.kt @@ -1,4 +1,4 @@ -package com.getcode.ui.components +package com.getcode.ui.theme import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.* diff --git a/common/components/src/main/kotlin/com/getcode/ui/components/CodeButton.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeButton.kt similarity index 71% rename from common/components/src/main/kotlin/com/getcode/ui/components/CodeButton.kt rename to ui/components/src/main/kotlin/com/getcode/ui/theme/CodeButton.kt index 517796851..9914d6337 100644 --- a/common/components/src/main/kotlin/com/getcode/ui/components/CodeButton.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeButton.kt @@ -1,12 +1,21 @@ -package com.getcode.ui.components +package com.getcode.ui.theme import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.* -import androidx.compose.material.* +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.Button +import androidx.compose.material.ButtonColors +import androidx.compose.material.ButtonDefaults import androidx.compose.material.ButtonDefaults.elevation -import androidx.compose.material.ripple.LocalRippleTheme -import androidx.compose.material.ripple.RippleAlpha -import androidx.compose.material.ripple.RippleTheme +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.LocalMinimumInteractiveComponentEnforcement +import androidx.compose.material.ProvideTextStyle +import androidx.compose.material.Text +import androidx.compose.material.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.derivedStateOf @@ -19,11 +28,19 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.isUnspecified -import com.getcode.theme.* +import com.getcode.theme.CodeTheme +import com.getcode.theme.Transparent +import com.getcode.theme.White +import com.getcode.theme.White10 +import com.getcode.theme.White20 +import com.getcode.theme.White50 +import com.getcode.ui.components.R import com.getcode.ui.utils.addIf import com.getcode.ui.utils.measured import com.getcode.ui.utils.plus @@ -46,9 +63,11 @@ fun CodeButton( top = CodeTheme.dimens.grid.x2, bottom = CodeTheme.dimens.grid.x2, ), + overrideContentPadding: Boolean = false, buttonState: ButtonState = ButtonState.Bordered, textColor: Color = Color.Unspecified, shape: Shape = CodeTheme.shapes.small, + style: TextStyle = CodeTheme.typography.textMedium, onClick: () -> Unit, ) { CodeButton( @@ -59,8 +78,11 @@ fun CodeButton( enabled = enabled, buttonState = buttonState, contentPadding = contentPadding, + overrideContentPadding = overrideContentPadding, shape = shape, contentColor = textColor, + style = style, + sizeKey = text ) { Text(text = text) } @@ -80,7 +102,10 @@ fun CodeButton( top = CodeTheme.dimens.grid.x3, bottom = CodeTheme.dimens.grid.x3, ), + overrideContentPadding: Boolean = false, contentColor: Color = Color.Unspecified, + style: TextStyle = CodeTheme.typography.textMedium, + sizeKey: Any? = null, content: @Composable RowScope.() -> Unit, ) { val isEnabled by remember(enabled, isLoading, isSuccess) { @@ -89,22 +114,22 @@ fun CodeButton( val isSuccessful by remember(isSuccess) { derivedStateOf { isSuccess } } - val colors = getButtonColors(buttonState, contentColor) + + val colors = getButtonColors(enabled, buttonState, contentColor) val border = getButtonBorder(buttonState, isEnabled) - val ripple = getRipple( - buttonState = buttonState, - contentColor = colors.contentColor(enabled = isEnabled).value - ) + val ripple = getRipple(buttonState = buttonState) CompositionLocalProvider( LocalMinimumInteractiveComponentEnforcement provides false, - LocalRippleTheme provides ripple + LocalIndication provides ripple ) { - var size by remember { + var size by remember(sizeKey) { mutableStateOf(DpSize.Unspecified) } + val cp = (if (overrideContentPadding) PaddingValues(0.dp) else ButtonDefaults.ContentPadding).plus(contentPadding) + Button( onClick = onClick, modifier = Modifier @@ -119,7 +144,7 @@ fun CodeButton( pressedElevation = 0.dp ), shape = shape, - contentPadding = ButtonDefaults.ContentPadding.plus(contentPadding) + contentPadding = cp ) { when { isLoading -> { @@ -135,13 +160,13 @@ fun CodeButton( Icon( modifier = Modifier.requiredSize(CodeTheme.dimens.grid.x3), painter = painterResource(id = R.drawable.ic_check), - tint = Color.Unspecified, + tint = CodeTheme.colors.success, contentDescription = "", ) } else -> { - ProvideTextStyle(value = CodeTheme.typography.textMedium) { + ProvideTextStyle(value = style) { content() } } @@ -153,33 +178,19 @@ fun CodeButton( @Composable fun getRipple( buttonState: ButtonState, - contentColor: Color -): RippleTheme { - return remember(buttonState, contentColor) { - object : RippleTheme { - @Composable - override fun defaultColor(): Color { - return when (buttonState) { - ButtonState.Bordered -> White - ButtonState.Filled -> BrandLight - ButtonState.Filled10 -> White50 - ButtonState.Subtle -> White - } - } - - @Composable - override fun rippleAlpha(): RippleAlpha { - return RippleTheme.defaultRippleAlpha( - contentColor, - lightTheme = true - ) - } - } +) = ripple( + bounded = true, + color = when (buttonState) { + ButtonState.Bordered -> White + ButtonState.Filled -> CodeTheme.colors.brandLight + ButtonState.Filled10 -> White50 + ButtonState.Subtle -> White } -} +) @Composable fun getButtonColors( + enabled: Boolean, buttonState: ButtonState = ButtonState.Bordered, textColor: Color = Color.Unspecified, ): ButtonColors { @@ -194,7 +205,7 @@ fun getButtonColors( ButtonState.Bordered -> ButtonDefaults.outlinedButtonColors( backgroundColor = Transparent, - disabledContentColor = Color.LightGray, + disabledContentColor = Color.White.copy(0.30f), contentColor = textColor.takeOrElse { Color.LightGray } ) @@ -209,7 +220,7 @@ fun getButtonColors( ButtonDefaults.outlinedButtonColors( backgroundColor = Transparent, disabledContentColor = Transparent, - contentColor = textColor.takeOrElse { BrandLight }, + contentColor = if (enabled) textColor.takeOrElse { CodeTheme.colors.brandLight } else Color.White.copy(0.30f) ) } } diff --git a/common/components/src/main/kotlin/com/getcode/ui/components/CodeCircularProgressIndicator.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeCircularProgressIndicator.kt similarity index 95% rename from common/components/src/main/kotlin/com/getcode/ui/components/CodeCircularProgressIndicator.kt rename to ui/components/src/main/kotlin/com/getcode/ui/theme/CodeCircularProgressIndicator.kt index d23d0f1f3..f705b2fd0 100644 --- a/common/components/src/main/kotlin/com/getcode/ui/components/CodeCircularProgressIndicator.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeCircularProgressIndicator.kt @@ -1,4 +1,4 @@ -package com.getcode.ui.components +package com.getcode.ui.theme import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.ProgressIndicatorDefaults diff --git a/common/components/src/main/kotlin/com/getcode/ui/components/CodeKeyPad.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeKeyPad.kt similarity index 90% rename from common/components/src/main/kotlin/com/getcode/ui/components/CodeKeyPad.kt rename to ui/components/src/main/kotlin/com/getcode/ui/theme/CodeKeyPad.kt index 318e3b483..f0b5f7f4e 100644 --- a/common/components/src/main/kotlin/com/getcode/ui/components/CodeKeyPad.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeKeyPad.kt @@ -1,10 +1,11 @@ -package com.getcode.ui.components +package com.getcode.ui.theme import android.view.MotionEvent import androidx.compose.animation.animateColor import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.Image +import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -18,9 +19,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.material.LocalContentColor import androidx.compose.material.Text -import androidx.compose.material.ripple.LocalRippleTheme -import androidx.compose.material.ripple.RippleAlpha -import androidx.compose.material.ripple.RippleTheme +import androidx.compose.material.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -35,11 +34,10 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInteropFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import com.getcode.theme.Brand import com.getcode.theme.CodeTheme import com.getcode.theme.Transparent -import com.getcode.theme.White import com.getcode.theme.White10 +import com.getcode.ui.components.R import com.getcode.ui.utils.rememberedClickable import java.text.DecimalFormatSymbols @@ -127,7 +125,8 @@ private fun KeyBoardButton( if (!isSelected) Transparent else White10 } - CompositionLocalProvider(LocalRippleTheme provides RippleCustomTheme) { + val ripple = remember { ripple(bounded = false, color = Color.White) } + CompositionLocalProvider(LocalIndication provides ripple) { Box( modifier = modifier .background(bgColor) @@ -182,20 +181,4 @@ private fun KeyBoardButton( } } } -} - -private object RippleCustomTheme : RippleTheme { - @Composable - override fun defaultColor() = - RippleTheme.defaultRippleColor( - White, - lightTheme = true - ) - - @Composable - override fun rippleAlpha(): RippleAlpha = - RippleTheme.defaultRippleAlpha( - Brand, - lightTheme = true - ) } \ No newline at end of file diff --git a/common/components/src/main/kotlin/com/getcode/ui/components/CodeScaffold.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt similarity index 98% rename from common/components/src/main/kotlin/com/getcode/ui/components/CodeScaffold.kt rename to ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt index 4b13f4d27..bec86a084 100644 --- a/common/components/src/main/kotlin/com/getcode/ui/components/CodeScaffold.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt @@ -1,4 +1,4 @@ -package com.getcode.ui.components +package com.getcode.ui.theme import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues diff --git a/common/components/src/main/kotlin/com/getcode/ui/components/CodeSeedView.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSeedView.kt similarity index 98% rename from common/components/src/main/kotlin/com/getcode/ui/components/CodeSeedView.kt rename to ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSeedView.kt index 8b08ed616..b0d85513d 100644 --- a/common/components/src/main/kotlin/com/getcode/ui/components/CodeSeedView.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSeedView.kt @@ -1,4 +1,4 @@ -package com.getcode.ui.components +package com.getcode.ui.theme import androidx.compose.ui.tooling.preview.Preview import androidx.compose.foundation.layout.* diff --git a/common/components/src/main/kotlin/com/getcode/ui/components/CodeSnackbar.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt similarity index 96% rename from common/components/src/main/kotlin/com/getcode/ui/components/CodeSnackbar.kt rename to ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt index 0f403e426..bf6cd72e8 100644 --- a/common/components/src/main/kotlin/com/getcode/ui/components/CodeSnackbar.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt @@ -1,4 +1,4 @@ -package com.getcode.ui.components +package com.getcode.ui.theme import androidx.compose.material.Snackbar import androidx.compose.material.SnackbarData diff --git a/common/components/src/main/kotlin/com/getcode/ui/components/CodeSwitch.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSwitch.kt similarity index 98% rename from common/components/src/main/kotlin/com/getcode/ui/components/CodeSwitch.kt rename to ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSwitch.kt index cd6b4b665..824cb51dd 100644 --- a/common/components/src/main/kotlin/com/getcode/ui/components/CodeSwitch.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSwitch.kt @@ -1,4 +1,4 @@ -package com.getcode.ui.components +package com.getcode.ui.theme import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Canvas diff --git a/app/src/main/java/com/getcode/ui/utils/AnimationUtils.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/AnimationUtils.kt similarity index 100% rename from app/src/main/java/com/getcode/ui/utils/AnimationUtils.kt rename to ui/components/src/main/kotlin/com/getcode/ui/utils/AnimationUtils.kt diff --git a/ui/components/src/main/kotlin/com/getcode/ui/utils/AutoSizeText.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/AutoSizeText.kt new file mode 100644 index 000000000..d396b6100 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/utils/AutoSizeText.kt @@ -0,0 +1,103 @@ +package com.getcode.ui.utils + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.layout.layout +import androidx.compose.ui.node.Ref +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.TextUnit +import kotlin.math.roundToInt + +sealed interface ConstraintMode { + data object Free : ConstraintMode + data class AutoSize(val minimum: TextStyle) : ConstraintMode +} + +fun Modifier.constrain( + mode: ConstraintMode, + text: String, + style: TextStyle, + frameConstraints: Constraints, + onTextSizeDetermined: (TextUnit) -> Unit +): Modifier = this.composed { + val textMeasurer = rememberTextMeasurer() + val autosizeTextMeasurer = remember(textMeasurer) { AutoSizeTextMeasurer(textMeasurer) } + val textLayoutResult = remember { Ref() } + var flag by remember { mutableStateOf(Unit, neverEqualPolicy()) } + + Modifier.addIf(mode is ConstraintMode.AutoSize) { + Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val result = autosizeTextMeasurer.measure( + text = AnnotatedString(text), + style = style, + constraints = Constraints( + maxWidth = (frameConstraints.maxWidth * 0.85f).roundToInt(), + minHeight = 0 + ), + minFontSize = (mode as ConstraintMode.AutoSize).minimum.fontSize, + maxFontSize = style.fontSize, + autosizeGranularity = 100 + ) + + textLayoutResult.value = result + flag = Unit + + onTextSizeDetermined(result.layoutInput.style.fontSize) + + layout(placeable.width, placeable.height) { + placeable.placeRelative(0, 0) + } + } + } +} + + +fun Modifier.constrain( + mode: ConstraintMode, + state: TextFieldState, + style: TextStyle, + frameConstraints: Constraints, + onTextSizeDetermined: (TextUnit) -> Unit +): Modifier = this.composed { + val textMeasurer = rememberTextMeasurer() + val autosizeTextMeasurer = remember(textMeasurer) { AutoSizeTextMeasurer(textMeasurer) } + val textLayoutResult = remember { Ref() } + var flag by remember { mutableStateOf(Unit, neverEqualPolicy()) } + + Modifier.addIf(mode is ConstraintMode.AutoSize) { + Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val result = autosizeTextMeasurer.measure( + text = AnnotatedString(state.text.toString()), + style = style, + constraints = Constraints( + maxWidth = (frameConstraints.maxWidth * 0.85f).roundToInt(), + minHeight = 0 + ), + minFontSize = (mode as ConstraintMode.AutoSize).minimum.fontSize, + maxFontSize = style.fontSize, + autosizeGranularity = 100 + ) + + textLayoutResult.value = result + flag = Unit + + onTextSizeDetermined(result.layoutInput.style.fontSize) + + layout(placeable.width, placeable.height) { + placeable.placeRelative(0, 0) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/utils/AutoSizeTextMeasurer.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/AutoSizeTextMeasurer.kt similarity index 100% rename from app/src/main/java/com/getcode/ui/utils/AutoSizeTextMeasurer.kt rename to ui/components/src/main/kotlin/com/getcode/ui/utils/AutoSizeTextMeasurer.kt diff --git a/app/src/main/java/com/getcode/ui/utils/ColorTransformations.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/ColorTransformations.kt similarity index 95% rename from app/src/main/java/com/getcode/ui/utils/ColorTransformations.kt rename to ui/components/src/main/kotlin/com/getcode/ui/utils/ColorTransformations.kt index b05698556..ce24f7f04 100644 --- a/app/src/main/java/com/getcode/ui/utils/ColorTransformations.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/utils/ColorTransformations.kt @@ -39,6 +39,18 @@ val Rgb.color: Color get() = rgbToHls(this).color +val Color.saturation: Float + get() { + val max = maxOf(red, green, blue) + val min = minOf(red, green, blue) + return if (max == 0f) 0f else (max - min) / max + } + +fun Color.value(): Float { + return maxOf(red, green, blue) +} + + private fun colorToRgb(color: Color): Rgb { return Rgb( r = (color.red * 255), diff --git a/ui/components/src/main/kotlin/com/getcode/ui/utils/ComplementaryGradient.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/ComplementaryGradient.kt new file mode 100644 index 000000000..de5288ad2 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/utils/ComplementaryGradient.kt @@ -0,0 +1,78 @@ +package com.getcode.ui.utils + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import com.getcode.model.ID +import com.getcode.utils.sha512 + +private val resultMap: MutableMap> = mutableMapOf() + +fun generateComplementaryColorPalette(identifier: ID): Triple? { + val hash = runCatching { identifier.toByteArray().sha512() }.getOrNull() ?: return null + return resultMap.getOrPutIfNonNull(identifier) { + generateGradientColors(hash.toList()) + } +} + +private fun generateGradientColors(bytes: List): Triple { + // Calculate relative luminance for contrast ratio + fun Color.contrastRatioWithWhite(): Float { + val whiteLuminance = 1f // White's relative luminance is 1 + val colorLuminance = this.luminance() + return if (whiteLuminance > colorLuminance) { + (whiteLuminance + 0.05f) / (colorLuminance + 0.05f) + } else { + (colorLuminance + 0.05f) / (whiteLuminance + 0.05f) + } + } + + // Adjust color to meet contrast requirements + fun Color.ensureReadableWithWhite(): Color { + var adjustedColor = this + var attempts = 0 + // WCAG AA requires contrast ratio of 4.5:1 for normal text + while (adjustedColor.contrastRatioWithWhite() < 4.5f && attempts < 10) { + // Darken the color by reducing its value in HSV + adjustedColor = Color.hsv( + hue = adjustedColor.hue, + saturation = adjustedColor.saturation, + value = (adjustedColor.value() * 0.9f).coerceIn(0f, 1f) + ) + attempts++ + } + return adjustedColor + } + + // Generate base colors as before + val hue = (bytes.take(3).fold(0) { acc, byte -> + acc + byte.toUByte().toInt() + } % 360).toFloat() + + val saturation = 0.75f + val baseValue = 0.85f + val valueVariation = (bytes[3].toUByte().toInt() % 10) / 100f + val hueShift = 20f + + fun boundValue(value: Float) = value.coerceIn(0f, 1f) + + val startColor = Color.hsv( + hue = hue, + saturation = boundValue(saturation), + value = boundValue(baseValue - valueVariation) + ) + + val middleColor = Color.hsv( + hue = (hue + hueShift) % 360f, + saturation = boundValue(saturation * 0.95f), + value = boundValue(baseValue) + ) + + // Generate end color and ensure it meets contrast requirements + val endColor = Color.hsv( + hue = (hue + hueShift * 2) % 360f, + saturation = boundValue(saturation * 0.9f), + value = boundValue(baseValue + valueVariation) + ).ensureReadableWithWhite() + + return Triple(startColor, middleColor, endColor) +} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/utils/EightBitGenerator.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/EightBitGenerator.kt new file mode 100644 index 000000000..4211743a5 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/utils/EightBitGenerator.kt @@ -0,0 +1,90 @@ +package com.getcode.ui.utils + +import android.graphics.Bitmap +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import com.getcode.utils.sha512 +import kotlin.experimental.and +import kotlin.math.roundToInt + + +private val resultMap: MutableMap, Size>, ImageBitmap> = mutableMapOf() + +fun generateEightBitAvatar(data: List, size: Size): ImageBitmap? { + return resultMap.getOrPutIfNonNull(data to size) { + generateAvatar(data, size) + } +} + +private fun generateAvatar(data: List, size: Size): ImageBitmap? { + val hash = runCatching { data.toByteArray().sha512() }.getOrNull() ?: return null + val foregroundColor = rgbFromHash(hash.copyOfRange(0, 3)) + + val bitmap = Bitmap.createBitmap( + size.width.roundToInt(), + size.height.roundToInt(), + Bitmap.Config.ARGB_8888 + ) + val canvas = android.graphics.Canvas(bitmap) + val paint = Paint() + + val length = 10 + val rCount = length + val cCount = length / 2 + val cellSize = size.width / length.toFloat() + val inset = cellSize * 0.6f + + val bounds = RectF(0f, 0f, size.width, size.height) + val fullPath = Path().apply { addOval(bounds, Path.Direction.CCW) } + val maskPath = Path().apply { addOval(bounds.apply { inset(inset, inset) }, Path.Direction.CW) } + + val delta = Path().apply { + op(fullPath, maskPath, Path.Op.DIFFERENCE) + } + + val paths = mutableListOf() + + for (r in 0 until rCount) { + for (c in 0 until cCount) { + val i = r * cCount + c + val isEven = (hash[i] and 1) == 0.toByte() + if (isEven) { + val leftPath = createPath(r, c, cellSize) + if (!delta.intersects(leftPath)) { + paths.add(leftPath) + } + + val rightPath = createPath(r, length - c - 1, cellSize) + if (!delta.intersects(rightPath)) { + paths.add(rightPath) + } + } + } + } + + paint.color = foregroundColor.toAGColor() + paths.forEach { canvas.drawPath(it, paint) } + + return bitmap.asImageBitmap() +} + +private fun createPath(row: Int, col: Int, size: Float): Path { + return Path().apply { + addRect(col * size, row * size, (col + 1) * size, (row + 1) * size, Path.Direction.CW) + } +} + +private fun Path.intersects(other: Path): Boolean { + val result = Path() + result.op(this, other, Path.Op.INTERSECT) + return !result.isEmpty +} + +private fun rgbFromHash(hash: ByteArray): Color { + return Color(hash[0].toInt() and 0xFF, hash[1].toInt() and 0xFF, hash[2].toInt() and 0xFF) +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/utils/Geometry.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/Geometry.kt similarity index 100% rename from app/src/main/java/com/getcode/ui/utils/Geometry.kt rename to ui/components/src/main/kotlin/com/getcode/ui/utils/Geometry.kt diff --git a/common/components/src/main/kotlin/com/getcode/ui/utils/IntSize.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/IntSize.kt similarity index 100% rename from common/components/src/main/kotlin/com/getcode/ui/utils/IntSize.kt rename to ui/components/src/main/kotlin/com/getcode/ui/utils/IntSize.kt diff --git a/app/src/main/java/com/getcode/ui/utils/KeepScreenOn.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/KeepScreenOn.kt similarity index 100% rename from app/src/main/java/com/getcode/ui/utils/KeepScreenOn.kt rename to ui/components/src/main/kotlin/com/getcode/ui/utils/KeepScreenOn.kt diff --git a/app/src/main/java/com/getcode/ui/utils/Keyboard.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/Keyboard.kt similarity index 100% rename from app/src/main/java/com/getcode/ui/utils/Keyboard.kt rename to ui/components/src/main/kotlin/com/getcode/ui/utils/Keyboard.kt diff --git a/ui/components/src/main/kotlin/com/getcode/ui/utils/LazyList.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/LazyList.kt new file mode 100644 index 000000000..6fd50a06c --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/utils/LazyList.kt @@ -0,0 +1,112 @@ +package com.getcode.ui.utils + + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable + + +fun LazyListScope.animatedItem( + key: Any? = null, + contentType: Any? = null, + visible: Boolean, + enter: EnterTransition? = null, + exit: ExitTransition? = null, + content: @Composable LazyItemScope.() -> Unit +) { + item(key = key, contentType = contentType) { + if (enter != null && exit != null) { + AnimatedVisibility( + visible = visible, + enter = enter, + exit = exit, + ) { + content() + } + } else { + AnimatedVisibility( + visible = visible, + ) { + content() + } + } + } +} + +fun LazyListState.isScrolledToTheEnd() = + layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1 + +fun LazyListState.isScrolledToTheBeginning() = + (layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0) == 0 + +suspend fun LazyListState.scrollToItemWithFullVisibility(to: Int) { + // 1️⃣ Scroll to the item initially + scrollToItem(to) + + // 2️⃣ Fetch updated layout info + val layoutInfo = layoutInfo + val itemInfo = layoutInfo.visibleItemsInfo.find { it.index == to } + + if (itemInfo != null) { + val viewportStart = layoutInfo.viewportStartOffset + val viewportEnd = layoutInfo.viewportEndOffset + + val itemStart = itemInfo.offset + val itemEnd = itemInfo.offset + itemInfo.size + + println( + "ItemStart: $itemStart, ItemEnd: $itemEnd, ViewportStart: $viewportStart, ViewportEnd: $viewportEnd" + ) + + // 3️⃣ Determine if the item is partially clipped upwards + if (itemStart < viewportStart) { + val scrollAmount = (viewportStart - itemStart).coerceAtLeast(0) + println("Item is clipped upwards, scrolling upwards by $scrollAmount") + scrollBy(-scrollAmount.toFloat()) // Scroll upwards + } + + // 4️⃣ Determine if the item is partially clipped downwards + if (itemEnd > viewportEnd) { + val scrollAmount = (itemEnd - viewportEnd).coerceAtLeast(0) + println("Item is clipped downwards, scrolling downwards by $scrollAmount") + scrollBy(scrollAmount.toFloat()) // Scroll downwards + } + + // 5️⃣ If the item is still misaligned, enforce alignment manually + val fullyVisible = itemStart >= viewportStart && itemEnd <= viewportEnd + if (!fullyVisible) { + println("Item is still misaligned, performing final alignment") + scrollToItem(to, scrollOffset = 0) + } + } else { + // 6️⃣ Fallback alignment + println("Item not found in visibleItemsInfo, performing fallback alignment") + scrollToItem(to, scrollOffset = 0) + } +} + +suspend fun LazyListState.animateScrollToItemWithFullVisibility(to: Int) { + val previousItemIndex = to - 1 + + // First scroll to bring the item into view + animateScrollToItem(previousItemIndex) + + // Dynamically calculate the correct offset + val itemInfo = layoutInfo.visibleItemsInfo + .find { it.index == previousItemIndex } + + itemInfo?.let { + val viewportEnd = layoutInfo.viewportEndOffset + val offsetFromEnd = viewportEnd - (it.offset + it.size) + + // Scroll only if the item isn't sufficiently visible + if (offsetFromEnd > 0) { + animateScrollToItem(previousItemIndex, scrollOffset = it.offset) + } + } +} diff --git a/ui/components/src/main/kotlin/com/getcode/ui/utils/Maps.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/Maps.kt new file mode 100644 index 000000000..aa523314c --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/utils/Maps.kt @@ -0,0 +1,14 @@ +package com.getcode.ui.utils + +internal inline fun MutableMap.getOrPutIfNonNull(key: K, defaultValue: () -> V?): V? { + val value = get(key) + return if (value == null) { + val answer = defaultValue() + if (answer != null) { + put(key, answer) + } + answer + } else { + value + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/utils/MeasureScope.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/MeasureScope.kt similarity index 100% rename from app/src/main/java/com/getcode/ui/utils/MeasureScope.kt rename to ui/components/src/main/kotlin/com/getcode/ui/utils/MeasureScope.kt diff --git a/common/components/src/main/kotlin/com/getcode/ui/utils/Modifier.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/Modifier.kt similarity index 85% rename from common/components/src/main/kotlin/com/getcode/ui/utils/Modifier.kt rename to ui/components/src/main/kotlin/com/getcode/ui/utils/Modifier.kt index 07e5dd865..51cda0c41 100644 --- a/common/components/src/main/kotlin/com/getcode/ui/utils/Modifier.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/utils/Modifier.kt @@ -9,7 +9,7 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.LazyGridState -import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -25,9 +25,14 @@ import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.PaintingStyle +import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.input.pointer.pointerInteropFilter @@ -39,7 +44,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.offset -import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme inline fun Modifier.addIf( @@ -53,6 +57,15 @@ inline fun Modifier.addIf( } } +fun Modifier.noRippleClickable(enabled: Boolean = true, onClick: () -> Unit) = composed { + this.clickable( + enabled = enabled, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onClick + ) +} + fun Modifier.unboundedClickable( enabled: Boolean = true, role: Role = Role.Button, @@ -67,7 +80,7 @@ fun Modifier.unboundedClickable( enabled = enabled, role = role, interactionSource = interaction, - indication = rememberRipple(bounded = false, radius = rippleRadius), + indication = ripple(bounded = false, radius = rippleRadius), ) } @@ -198,7 +211,8 @@ fun Modifier.punchCircle(color: Color) = this.drawWithContent { drawContent() } -fun Modifier.withTopBorder(color: Color = BrandLight) = drawBehind { +@Composable +fun Modifier.withTopBorder(color: Color = CodeTheme.colors.brandLight) = drawBehind { val strokeWidth = Dp.Hairline.toPx() drawLine( color = color, @@ -242,27 +256,29 @@ fun Modifier.drawWithGradient( } private val gradientSize - @Composable get() = CodeTheme.dimens.staticGrid.x8 + @Composable get() = CodeTheme.dimens.staticGrid.x12 fun Modifier.verticalScrollStateGradient( scrollState: LazyListState, color: Color = Color.Unspecified, showAtStart: Boolean = true, + showAtStartAlways: Boolean = false, showAtEnd: Boolean = true, + showAtEndAlways: Boolean = false, isLongGradient: Boolean = false, ): Modifier = composed { val backgroundColor = color.takeOrElse { CodeTheme.colors.background } val gradientSizePx = with(LocalDensity.current) { gradientSize.toPx() } * if (isLongGradient) 1.5f else 1f this - .addIf(showAtStart && !scrollState.isScrolledToStart()) { + .addIf((showAtStart && !scrollState.isScrolledToStart()) || showAtStartAlways) { Modifier.drawWithGradient( color = backgroundColor, startY = { gradientSizePx }, endY = { 0f }, ) } - .addIf(showAtEnd && !scrollState.isScrolledToEnd()) { + .addIf((showAtEnd && !scrollState.isScrolledToEnd()) || showAtEndAlways) { Modifier.drawWithGradient( color = backgroundColor, startY = { size.height - gradientSizePx }, @@ -357,12 +373,34 @@ fun LazyGridState.isVerticallyScrolledToStart(): Boolean { return firstItem == null || firstItem.offset.y == 0 } -fun Modifier.footerShadow() = this.drawWithContent { - drawContent() - drawRect( - brush = Brush.verticalGradient( - endY = size.height * 0.12f, - colors = listOf(Color(0x10000000), Color.Transparent), - ), +@Composable +fun Modifier.fadingEdge( + brush: Brush = remember { + Brush.verticalGradient( + 0f to Color.Transparent, + 0.5f to Color.Black, + 1f to Color.Transparent + ) + } +) = this + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .drawWithContent { + drawContent() + drawRect(brush = brush, blendMode = BlendMode.DstIn) + } + +fun Modifier.dashedBorder( + strokeWidth: Dp, + dashWidth: Dp = strokeWidth, + gapWidth: Dp = dashWidth, + dashColor: Color, + shape: Shape +) = this.drawBehind { + val outline = shape.createOutline(size, layoutDirection, this) + val pathEffect = PathEffect.dashPathEffect(floatArrayOf(dashWidth.toPx(), gapWidth.toPx()), 0f) + drawOutline( + outline = outline, + color = dashColor, + style = Stroke(width = strokeWidth.toPx(), pathEffect = pathEffect) ) } \ No newline at end of file diff --git a/common/components/src/main/kotlin/com/getcode/ui/utils/PaddingValues.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/PaddingValues.kt similarity index 87% rename from common/components/src/main/kotlin/com/getcode/ui/utils/PaddingValues.kt rename to ui/components/src/main/kotlin/com/getcode/ui/utils/PaddingValues.kt index 490a50b56..cc5502c05 100644 --- a/common/components/src/main/kotlin/com/getcode/ui/utils/PaddingValues.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/utils/PaddingValues.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp fun PaddingValues.calculateVerticalPadding() = calculateTopPadding() + calculateBottomPadding() @@ -23,7 +24,11 @@ fun PaddingValues.calculateEndPadding(): Dp { @Composable fun PaddingValues.calculateHorizontalPadding(): Dp { val ldr = LocalLayoutDirection.current - return calculateLeftPadding(ldr) + calculateRightPadding(ldr) + return calculateHorizontalPadding(ldr) +} + +fun PaddingValues.calculateHorizontalPadding(layoutDirection: LayoutDirection): Dp { + return calculateLeftPadding(layoutDirection) + calculateRightPadding(layoutDirection) } @Composable diff --git a/ui/components/src/main/kotlin/com/getcode/ui/utils/PreviewParameters.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/PreviewParameters.kt new file mode 100644 index 000000000..1036bbeb5 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/utils/PreviewParameters.kt @@ -0,0 +1,18 @@ +package com.getcode.ui.utils + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.getcode.model.ID +import java.security.SecureRandom +import java.util.UUID +import kotlin.random.Random + +class UUIDPreviewParameterProvider(count: Int = 40) : PreviewParameterProvider { + override val values: Sequence = generateSequence { UUID.randomUUID() }.take(count) +} + +class IDPreviewParameterProvider(count: Int = 40): PreviewParameterProvider { + override val values: Sequence = generateSequence { + val random = SecureRandom() + List(32) { random.nextInt(256).toByte() } + }.take(count) +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/utils/RecompositionHighlighter.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/RecompositionHighlighter.kt similarity index 100% rename from app/src/main/java/com/getcode/ui/utils/RecompositionHighlighter.kt rename to ui/components/src/main/kotlin/com/getcode/ui/utils/RecompositionHighlighter.kt diff --git a/ui/components/src/main/kotlin/com/getcode/ui/utils/TextStyle.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/TextStyle.kt new file mode 100644 index 000000000..c74fdc9da --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/utils/TextStyle.kt @@ -0,0 +1,18 @@ +package com.getcode.ui.utils + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.text.TextStyle + +fun TextStyle.withDropShadow( + color: Color = Color.Black.copy(alpha = 0.5f), + offset: Offset = Offset(4f, 4f), + blurRadius: Float = 4f, +): TextStyle = copy( + shadow = Shadow( + color = color, + offset = offset, + blurRadius = blurRadius + ) +) \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/utils/TextUnit.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/TextUnit.kt similarity index 100% rename from app/src/main/java/com/getcode/ui/utils/TextUnit.kt rename to ui/components/src/main/kotlin/com/getcode/ui/utils/TextUnit.kt diff --git a/app/src/main/res/drawable/ic_message_status_delivered.xml b/ui/components/src/main/res/drawable/ic_message_status_delivered.xml similarity index 100% rename from app/src/main/res/drawable/ic_message_status_delivered.xml rename to ui/components/src/main/res/drawable/ic_message_status_delivered.xml diff --git a/app/src/main/res/drawable/ic_message_status_read.xml b/ui/components/src/main/res/drawable/ic_message_status_read.xml similarity index 100% rename from app/src/main/res/drawable/ic_message_status_read.xml rename to ui/components/src/main/res/drawable/ic_message_status_read.xml diff --git a/app/src/main/res/drawable/ic_message_status_sent.xml b/ui/components/src/main/res/drawable/ic_message_status_sent.xml similarity index 100% rename from app/src/main/res/drawable/ic_message_status_sent.xml rename to ui/components/src/main/res/drawable/ic_message_status_sent.xml diff --git a/app/src/main/res/drawable/ic_twitter_verified_badge.xml b/ui/components/src/main/res/drawable/ic_twitter_verified_badge.xml similarity index 100% rename from app/src/main/res/drawable/ic_twitter_verified_badge.xml rename to ui/components/src/main/res/drawable/ic_twitter_verified_badge.xml diff --git a/app/src/main/res/drawable/ic_twitter_verified_badge_gold.xml b/ui/components/src/main/res/drawable/ic_twitter_verified_badge_gold.xml similarity index 100% rename from app/src/main/res/drawable/ic_twitter_verified_badge_gold.xml rename to ui/components/src/main/res/drawable/ic_twitter_verified_badge_gold.xml diff --git a/app/src/main/res/drawable/ic_twitter_verified_badge_gray.xml b/ui/components/src/main/res/drawable/ic_twitter_verified_badge_gray.xml similarity index 100% rename from app/src/main/res/drawable/ic_twitter_verified_badge_gray.xml rename to ui/components/src/main/res/drawable/ic_twitter_verified_badge_gray.xml diff --git a/app/src/main/res/drawable/lock_app_dashed.xml b/ui/components/src/main/res/drawable/lock_app_dashed.xml similarity index 100% rename from app/src/main/res/drawable/lock_app_dashed.xml rename to ui/components/src/main/res/drawable/lock_app_dashed.xml diff --git a/ui/components/src/main/res/values/strings.xml b/ui/components/src/main/res/values/strings.xml new file mode 100644 index 000000000..71ad39341 --- /dev/null +++ b/ui/components/src/main/res/values/strings.xml @@ -0,0 +1,25 @@ + + + You revealed your identity to %1$s + %1$s revealed their identity to you + This person tipped you %1$s + 🙏 You thanked them + 🙏 %1$s thanked you for your tip + + 0 Unread Messages + 1 Unread Message + %d Unread Messages + + + @string/title_conversationUnreadCountEmpty + @string/title_conversationUnreadCountSingle + @string/title_conversationUnreadCountMany + @string/title_conversationUnreadCountMany + @string/title_conversationUnreadCountMany + @string/title_conversationUnreadCountMany + + + Message deleted by you + Message deleted by host + This message was deleted + \ No newline at end of file diff --git a/ui/navigation/.gitignore b/ui/navigation/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/ui/navigation/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/ui/navigation/build.gradle.kts b/ui/navigation/build.gradle.kts new file mode 100644 index 000000000..4f1d221be --- /dev/null +++ b/ui/navigation/build.gradle.kts @@ -0,0 +1,72 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_serialization) + id(Plugins.kotlin_parcelize) +} + +android { + namespace = "${Android.codeNamespace}.navigation" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.compose_compiler + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } +} + +dependencies { + implementation(project(":libs:models")) + implementation(project(":ui:resources")) + implementation(project(":ui:components")) + implementation(project(":ui:theme")) + + implementation(Libs.androidx_annotation) + api(Libs.kotlin_stdlib) + + api(Libs.rxjava) + api(Libs.rxandroid) + + implementation(platform(Libs.compose_bom)) + implementation(Libs.compose_ui) + implementation(Libs.compose_foundation) + implementation(Libs.compose_material) + implementation(Libs.compose_activities) + + implementation(Libs.androidx_lifecycle_runtime) + implementation(Libs.androidx_lifecycle_viewmodel) + implementation(Libs.androidx_navigation_fragment) + + implementation(Libs.timber) + + api(Libs.compose_voyager_navigation) + api(Libs.compose_voyager_navigation_transitions) + api(Libs.compose_voyager_navigation_bottomsheet) + api(Libs.compose_voyager_navigation_tabs) + api(Libs.compose_voyager_navigation_hilt) + + api(Libs.rinku) +} diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/NavScreenProvider.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavScreenProvider.kt new file mode 100644 index 000000000..dfcc6d0c9 --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavScreenProvider.kt @@ -0,0 +1,118 @@ +package com.getcode.navigation + +import android.os.Parcel +import android.os.Parcelable +import androidx.compose.ui.graphics.Color +import cafe.adriel.voyager.core.registry.ScreenProvider +import com.getcode.model.ID +import com.getcode.ui.components.restrictions.RestrictionType +import dev.theolm.rinku.DeepLink +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize + +sealed class NavScreenProvider : ScreenProvider { + + data class AppRestricted(val restrictionType: RestrictionType): NavScreenProvider() + + sealed class Login { + data class Home(val seed: String? = null) : NavScreenProvider() + data object SeedInput : NavScreenProvider() + data class NotificationPermission(val fromOnboarding: Boolean = false) : NavScreenProvider() + } + + sealed interface CreateAccount { + data object Start: NavScreenProvider() + data class NameEntry(val showInModal: Boolean = false) : NavScreenProvider() + data class AccessKey(val showInModal: Boolean = false) : NavScreenProvider() + data object Purchase: NavScreenProvider() + } + + data class AppHomeScreen(val deeplink: DeepLink? = null) : NavScreenProvider() + sealed class Room { + data object List : NavScreenProvider() + sealed class Lookup { + data object Entry : NavScreenProvider() + } + + data object Create: NavScreenProvider() + data class Messages( + val chatId: ID? = null, + val roomNumber: Long? = null, + ) : NavScreenProvider() + + data class Info( + val args: RoomInfoArgs = RoomInfoArgs(), + val returnToSender: Boolean = false + ) : NavScreenProvider() + + data class Preview( + val args: RoomInfoArgs = RoomInfoArgs(), + val returnToSender: Boolean = false + ) : NavScreenProvider() + + data class ChangeCover( + val id: ID + ) : NavScreenProvider() + + data class ChangeName( + val id: ID, + val title: String, + ): NavScreenProvider() + } + + data object Balance : NavScreenProvider() + data object Settings : NavScreenProvider() + + data object BetaFlags: NavScreenProvider() +} + +@Parcelize +data class LoginArgs( + val signInEntropy: String? = null, + val isPhoneLinking: Boolean = false, + val isNewAccount: Boolean = false, + val phoneNumber: String? = null +) : Parcelable + +@Parcelize +data class RoomInfoArgs( + val roomId: ID? = null, + val roomNumber: Long = 0, + val roomTitle: String? = null, + val memberCount: Int = 0, + val ownerId: ID? = null, + val hostName: String? = null, + val messagingFeeQuarks: Long = 0, + val gradientColors: GradientColors = GradientColors( + Triple( + Color(0xFFFFBB00), + Color(0xFF7306B7), + Color(0xFF3E32C4), + ) + ) +) : Parcelable + +@Parcelize +data class GradientColors( + val triple: Triple +) : Parcelable { + private constructor(parcel: Parcel) : this( + Triple( + Color(parcel.readLong()), + Color(parcel.readLong()), + Color(parcel.readLong()) + ) + ) + + companion object : Parceler { + override fun GradientColors.write(parcel: Parcel, flags: Int) { + parcel.writeLong(triple.first.value.toLong()) + parcel.writeLong(triple.second.value.toLong()) + parcel.writeLong(triple.third.value.toLong()) + } + + override fun create(parcel: Parcel): GradientColors { + return GradientColors(parcel) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/navigation/core/BottomSheetNavigator.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/core/BottomSheetNavigator.kt similarity index 89% rename from app/src/main/java/com/getcode/navigation/core/BottomSheetNavigator.kt rename to ui/navigation/src/main/kotlin/com/getcode/navigation/core/BottomSheetNavigator.kt index bb1cf35ba..a545e36aa 100644 --- a/app/src/main/java/com/getcode/navigation/core/BottomSheetNavigator.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/core/BottomSheetNavigator.kt @@ -14,12 +14,14 @@ import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -35,9 +37,10 @@ import cafe.adriel.voyager.core.stack.Stack import cafe.adriel.voyager.navigator.CurrentScreen import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.compositionUniqueId -import com.getcode.manager.ModalManager +import com.getcode.theme.Black40 import com.getcode.theme.CodeTheme import com.getcode.theme.extraLarge +import com.getcode.ui.utils.LocalSheetGesturesState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -54,7 +57,7 @@ val LocalBottomSheetNavigator: ProvidableCompositionLocal fun BottomSheetNavigator( modifier: Modifier = Modifier, hideOnBackPress: Boolean = true, - scrimColor: Color = CodeTheme.colors.surface.copy(alpha = 0.32f), + scrimColor: Color = Black40, sheetShape: Shape = CodeTheme.shapes.extraLarge.copy( bottomStart = ZeroCornerSize, bottomEnd = ZeroCornerSize ), @@ -63,9 +66,10 @@ fun BottomSheetNavigator( sheetContentColor: Color = CodeTheme.colors.onSurface, sheetGesturesEnabled: Boolean = true, skipHalfExpanded: Boolean = true, - animationSpec: AnimationSpec = tween(), + animationSpec: AnimationSpec = tween(), key: String = compositionUniqueId(), sheetContent: BottomSheetNavigatorContent = { CurrentScreen() }, + onHide: () -> Unit = { }, content: BottomSheetNavigatorContent ) { Navigator(HiddenBottomSheetScreen, onBackPressed = null, key = key) { navigator -> @@ -91,11 +95,18 @@ fun BottomSheetNavigator( bottomSheetNavigator.hide() coroutineScope.launch { delay(1_000) - ModalManager.clear() + onHide() } } - CompositionLocalProvider(LocalBottomSheetNavigator provides bottomSheetNavigator) { + var gesturesEnabled by rememberSaveable(sheetGesturesEnabled) { + mutableStateOf(sheetGesturesEnabled) + } + + CompositionLocalProvider( + LocalBottomSheetNavigator provides bottomSheetNavigator, + LocalSheetGesturesState provides { gesturesEnabled = it }, + ) { ModalBottomSheetLayout( modifier = modifier, scrimColor = scrimColor, @@ -104,9 +115,13 @@ fun BottomSheetNavigator( sheetElevation = sheetElevation, sheetBackgroundColor = sheetBackgroundColor, sheetContentColor = sheetContentColor, - sheetGesturesEnabled = sheetGesturesEnabled, + sheetGesturesEnabled = gesturesEnabled, sheetContent = { - BottomSheetNavigatorBackHandler(bottomSheetNavigator, sheetState, hideOnBackPress) + BottomSheetNavigatorBackHandler( + bottomSheetNavigator, + sheetState, + hideOnBackPress + ) sheetContent(bottomSheetNavigator) }, content = { @@ -243,7 +258,8 @@ object HiddenBottomSheetScreen : Screen { } @ExperimentalMaterialApi -@Composable fun BottomSheetNavigatorBackHandler( +@Composable +fun BottomSheetNavigatorBackHandler( navigator: BottomSheetNavigator, sheetState: ModalBottomSheetState, hideOnBackPress: Boolean @@ -257,7 +273,7 @@ object HiddenBottomSheetScreen : Screen { class SheetStacks( map: LinkedHashMap> -): Stack>> by map.toMutableStateStack() { +) : Stack>> by map.toMutableStateStack() { fun pushTo(stackRoot: Screen, screen: Screen) { val stack = items.firstOrNull { it.first == stackRoot } ?: return @@ -273,6 +289,7 @@ class SheetStacks( val stack = lastItemOrNull ?: return pushTo(stack.first, screen) } + infix fun push(screen: Screen) { push(screen to listOf(screen)) } diff --git a/app/src/main/java/com/getcode/navigation/core/CodeNavigator.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/core/CodeNavigator.kt similarity index 97% rename from app/src/main/java/com/getcode/navigation/core/CodeNavigator.kt rename to ui/navigation/src/main/kotlin/com/getcode/navigation/core/CodeNavigator.kt index 472e03288..1b28c1687 100644 --- a/app/src/main/java/com/getcode/navigation/core/CodeNavigator.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/core/CodeNavigator.kt @@ -37,6 +37,8 @@ class NavigatorNull : CodeNavigator { override fun replaceAll(items: List, inSheet: Boolean) = Unit + override fun isAtRoot(): Boolean = true + override fun pop(): Boolean = false override fun popWithResult(result: T) = false @@ -78,6 +80,7 @@ interface CodeNavigator { fun replaceAll(items: List, inSheet: Boolean = false) + fun isAtRoot(): Boolean fun pop(): Boolean fun popWithResult(result: T): Boolean diff --git a/app/src/main/java/com/getcode/navigation/core/CombinedNavigator.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/core/CombinedNavigator.kt similarity index 95% rename from app/src/main/java/com/getcode/navigation/core/CombinedNavigator.kt rename to ui/navigation/src/main/kotlin/com/getcode/navigation/core/CombinedNavigator.kt index 6a8c874a8..e703a9d71 100644 --- a/app/src/main/java/com/getcode/navigation/core/CombinedNavigator.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/core/CombinedNavigator.kt @@ -10,10 +10,8 @@ import cafe.adriel.voyager.navigator.Navigator import com.getcode.navigation.screens.AppScreen import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import timber.log.Timber class CombinedNavigator( var sheetNavigator: BottomSheetNavigator @@ -105,6 +103,14 @@ class CombinedNavigator( } } + override fun isAtRoot(): Boolean { + return if (isVisible) { + sheetNavigator.items.count() == 1 + } else { + screensNavigator?.items?.count() == 1 + } + } + override fun pop(): Boolean { return if (isVisible) { sheetNavigator.pop() diff --git a/app/src/main/java/com/getcode/ui/utils/Voyager.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/extensions/Voyager.kt similarity index 93% rename from app/src/main/java/com/getcode/ui/utils/Voyager.kt rename to ui/navigation/src/main/kotlin/com/getcode/navigation/extensions/Voyager.kt index 697ce9af3..81011fce4 100644 --- a/app/src/main/java/com/getcode/ui/utils/Voyager.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/extensions/Voyager.kt @@ -1,22 +1,23 @@ -package com.getcode.ui.utils +package com.getcode.navigation.extensions import android.content.Context import androidx.activity.ComponentActivity import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.HasDefaultViewModelProviderFactory import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.CreationExtras import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.hilt.VoyagerHiltViewModelFactories import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.ui.utils.getActivity @Composable -inline fun Screen.getActivityScopedViewModel(): T { +inline fun getActivityScopedViewModel(): T { val activity = LocalContext.current.getActivity() as ComponentActivity val defaultFactory = (LocalLifecycleOwner.current as HasDefaultViewModelProviderFactory) val viewModelStore = LocalContext.current.getActivity()!!.viewModelStore diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/modal/FullScreenModalScreen.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/modal/FullScreenModalScreen.kt new file mode 100644 index 000000000..aba2c87e6 --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/modal/FullScreenModalScreen.kt @@ -0,0 +1,55 @@ +package com.getcode.navigation.modal + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import cafe.adriel.voyager.core.screen.Screen +import com.getcode.navigation.screens.ModalContent +import com.getcode.theme.CodeTheme + +sealed interface ModalHeightMetric { + data class Weight(val weight: Float) : ModalHeightMetric + data object WrapContent : ModalHeightMetric +} + +interface FullScreenModalScreen : Screen, ModalContent { + + @Composable + fun ModalContent() + + @Composable + override fun Content() { + ModalContent() + } +} + +interface ModalScreen : Screen, ModalContent { + + val modalHeight: ModalHeightMetric + @Composable get() = ModalHeightMetric.Weight(CodeTheme.dimens.modalHeightRatio) + + @Composable + fun ModalContent() + + @Composable + override fun Content() { + Column( + modifier = Modifier + .fillMaxWidth() + .then( + modalHeight.let { mh -> + when (mh) { + is ModalHeightMetric.Weight -> Modifier.fillMaxHeight(mh.weight) + ModalHeightMetric.WrapContent -> Modifier.wrapContentHeight() + } + } + ) + ) { + ModalContent() + } + } +} \ No newline at end of file diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/screens/ChildNavTab.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/screens/ChildNavTab.kt new file mode 100644 index 000000000..e46e4722a --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/screens/ChildNavTab.kt @@ -0,0 +1,7 @@ +package com.getcode.navigation.screens + +import cafe.adriel.voyager.navigator.tab.Tab + +interface ChildNavTab: Tab { + val ordinal: Int +} \ No newline at end of file diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/screens/ContextSheet.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/screens/ContextSheet.kt new file mode 100644 index 000000000..2eed89eb1 --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/screens/ContextSheet.kt @@ -0,0 +1,102 @@ +package com.getcode.navigation.screens + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Divider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.util.fastForEachIndexed +import cafe.adriel.voyager.core.screen.Screen +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 +import com.getcode.ui.components.contextmenu.ContextMenuAction +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +sealed interface ContextMenuStyle { + @get:Composable + val color: Color + + data object Default: ContextMenuStyle { + override val color: Color + @Composable get() = Color(0xFF171921) + } + + data object Themed: ContextMenuStyle { + override val color: Color + @Composable get() = CodeTheme.colors.surfaceVariant + } +} + +class ContextSheet( + private val actions: List, + private val style: ContextMenuStyle = ContextMenuStyle.Default +) : Screen { + + @Composable + override fun Content() { + Column( + modifier = Modifier + .background(style.color) + .padding(top = CodeTheme.dimens.inset) + .navigationBarsPadding() + ) { + val navigator = LocalCodeNavigator.current + val composeScope = rememberCoroutineScope() + actions.fastForEachIndexed { index, action -> + Row( + modifier = Modifier + .clickable { + composeScope.launch { + navigator.hide() + if (action.delayUponSelection) { + delay(300) + } + action.onSelect() + } + } + .fillMaxWidth() + .padding( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.grid.x3 + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2) + ) { + Image( + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x3), + painter = action.painter, + contentDescription = null, + colorFilter = ColorFilter.tint( + if (action.isDestructive) CodeTheme.colors.errorText else CodeTheme.colors.textMain + ) + ) + Text( + text = action.title, + style = CodeTheme.typography.textMedium.copy( + color = if (action.isDestructive) CodeTheme.colors.errorText else CodeTheme.colors.textMain + ), + modifier = Modifier.weight(1f) + ) + } + if (index < actions.lastIndex) { + Divider(color = White05) + } + } + } + } +} \ No newline at end of file diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/screens/Screens.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/screens/Screens.kt new file mode 100644 index 000000000..64b60770b --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/screens/Screens.kt @@ -0,0 +1,27 @@ +package com.getcode.navigation.screens + +import androidx.compose.runtime.Composable +import cafe.adriel.voyager.core.screen.Screen +import kotlinx.coroutines.flow.MutableStateFlow +import timber.log.Timber + +interface NamedScreen { + + val name: String? + @Composable get() = null + + val hasName: Boolean + @Composable get() = !name.isNullOrEmpty() +} + +abstract class AppScreen: Screen { + var result = MutableStateFlow(null) + + fun onResult(obj: T) { + Timber.d("onResult=$obj") + result.value = obj + } +} + +interface ModalContent +interface ModalRoot : ModalContent \ No newline at end of file diff --git a/app/src/main/java/com/getcode/navigation/transitions/SheetSlideTransition.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/transitions/SheetSlideTransition.kt similarity index 98% rename from app/src/main/java/com/getcode/navigation/transitions/SheetSlideTransition.kt rename to ui/navigation/src/main/kotlin/com/getcode/navigation/transitions/SheetSlideTransition.kt index adff88ac2..2d555b55b 100644 --- a/app/src/main/java/com/getcode/navigation/transitions/SheetSlideTransition.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/transitions/SheetSlideTransition.kt @@ -71,7 +71,7 @@ fun BottomSheetScreenTransition( targetState = lastItem, transitionSpec = transition, modifier = modifier, - label = "screen transition" + label = "screen transition", ) { screen -> navigator.saveableState("transition", screen = screen) { content(screen) @@ -86,4 +86,4 @@ fun BottomSheetScreenTransition( enum class SlideOrientation { Horizontal, Vertical -} +} \ No newline at end of file diff --git a/ui/navigation/src/main/kotlin/com/getcode/ui/utils/DisableSheetGestures.kt b/ui/navigation/src/main/kotlin/com/getcode/ui/utils/DisableSheetGestures.kt new file mode 100644 index 000000000..65737fa21 --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/ui/utils/DisableSheetGestures.kt @@ -0,0 +1,31 @@ +package com.getcode.ui.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch + +var LocalSheetGesturesState = compositionLocalOf<(Boolean) -> Unit> { { } } + +@Composable +fun DisableSheetGestures( + lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current +) { + val sheetGestureState = LocalSheetGesturesState.current + DisposableEffect(lifecycleOwner) { + val job = lifecycleOwner.lifecycleScope.launch { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + sheetGestureState(false) + } + } + onDispose { + job.cancel() + sheetGestureState(true) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/utils/RepeatOnLifecycle.kt b/ui/navigation/src/main/kotlin/com/getcode/ui/utils/RepeatOnLifecycle.kt similarity index 100% rename from app/src/main/java/com/getcode/ui/utils/RepeatOnLifecycle.kt rename to ui/navigation/src/main/kotlin/com/getcode/ui/utils/RepeatOnLifecycle.kt diff --git a/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt b/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt new file mode 100644 index 000000000..6ec3da8f9 --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt @@ -0,0 +1,23 @@ +package com.getcode.view + +import androidx.lifecycle.ViewModel +import com.getcode.util.resources.ResourceHelper +import io.reactivex.rxjava3.disposables.CompositeDisposable + +@Deprecated( + message = "Replaced With BaseViewModel2", + replaceWith = ReplaceWith("Use BaseViewModel2", "com.getcode.view.BaseViewModel2")) +abstract class BaseViewModel( + private val resources: ResourceHelper, +) : ViewModel() { + private val compositeDisposable = CompositeDisposable() + + override fun onCleared() { + super.onCleared() + compositeDisposable.clear() + } + + open fun setIsLoading(isLoading: Boolean) {} + + fun getString(resId: Int): String = resources.getString(resId) +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/BaseViewModel.kt b/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel2.kt similarity index 71% rename from app/src/main/java/com/getcode/view/BaseViewModel.kt rename to ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel2.kt index 4950b720e..e0ace8255 100644 --- a/app/src/main/java/com/getcode/view/BaseViewModel.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel2.kt @@ -2,8 +2,6 @@ package com.getcode.view import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.getcode.util.resources.ResourceHelper -import io.reactivex.rxjava3.disposables.CompositeDisposable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -15,24 +13,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.coroutines.CoroutineContext -@Deprecated( - message = "Replaced With BaseViewModel2", - replaceWith = ReplaceWith("Use BaseViewModel2", "com.getcode.view.BaseViewModel2")) -abstract class BaseViewModel( - private val resources: ResourceHelper, -) : ViewModel() { - private val compositeDisposable = CompositeDisposable() - - override fun onCleared() { - super.onCleared() - compositeDisposable.clear() - } - - open fun setIsLoading(isLoading: Boolean) {} - - fun getString(resId: Int): String = resources.getString(resId) -} - abstract class BaseViewModel2( initialState: ViewState, private val updateStateForEvent: (Event) -> (ViewState.() -> ViewState), @@ -61,4 +41,9 @@ abstract class BaseViewModel2( private fun setState(update: ViewState.() -> ViewState) { _stateFlow.value = _stateFlow.value.update() } -} \ No newline at end of file +} + +data class LoadingSuccessState( + val loading: Boolean = false, + val success: Boolean = false, +) \ No newline at end of file diff --git a/ui/resources/.gitignore b/ui/resources/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/ui/resources/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/common/resources/build.gradle.kts b/ui/resources/build.gradle.kts similarity index 85% rename from common/resources/build.gradle.kts rename to ui/resources/build.gradle.kts index 6f8c452c6..5351ff28e 100644 --- a/common/resources/build.gradle.kts +++ b/ui/resources/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } android { - namespace = "${Android.namespace}.util.resources" + namespace = "${Android.codeNamespace}.util.resources" compileSdk = Android.compileSdkVersion defaultConfig { minSdk = Android.minSdkVersion @@ -25,8 +25,6 @@ android { kotlinOptions { jvmTarget = Versions.java freeCompilerArgs += listOf( - "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", - "-Xuse-experimental=kotlinx.coroutines.FlowPreview", "-opt-in=kotlin.ExperimentalUnsignedTypes", "-opt-in=kotlin.RequiresOptIn" ) @@ -41,6 +39,8 @@ android { dependencies { api(Libs.androidx_annotation) + api(Libs.androidx_appcompat) + api(Libs.androidx_core) api(Libs.kotlin_stdlib) api(Libs.kotlinx_coroutines_core) api(Libs.kotlinx_coroutines_rx3) @@ -48,4 +48,6 @@ dependencies { implementation(platform(Libs.compose_bom)) implementation(Libs.compose_ui) implementation(Libs.compose_foundation) + + implementation(Libs.timber) } diff --git a/app/src/main/java/com/getcode/util/AndroidResources.kt b/ui/resources/src/main/java/com/getcode/util/resources/AndroidResources.kt similarity index 88% rename from app/src/main/java/com/getcode/util/AndroidResources.kt rename to ui/resources/src/main/java/com/getcode/util/resources/AndroidResources.kt index 6715687b4..311276abf 100644 --- a/app/src/main/java/com/getcode/util/AndroidResources.kt +++ b/ui/resources/src/main/java/com/getcode/util/resources/AndroidResources.kt @@ -1,4 +1,4 @@ -package com.getcode.util +package com.getcode.util.resources import android.annotation.SuppressLint import android.content.Context @@ -10,16 +10,11 @@ import androidx.annotation.RawRes import androidx.annotation.StringRes import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.res.ResourcesCompat -import com.getcode.BuildConfig -import com.getcode.util.resources.ResourceHelper -import com.getcode.util.resources.ResourceType -import dagger.hilt.android.qualifiers.ApplicationContext import timber.log.Timber import java.io.File -import javax.inject.Inject -class AndroidResources @Inject constructor( - @ApplicationContext private val context: Context, +class AndroidResources( + private val context: Context, ) : ResourceHelper { override fun getString(@StringRes resourceId: Int): String { @@ -91,7 +86,7 @@ class AndroidResources @Inject constructor( context.resources.getIdentifier( name, type.defType, - BuildConfig.APPLICATION_ID + context.packageName ) } }.let { if (it == 0) null else it } @@ -101,4 +96,12 @@ class AndroidResources @Inject constructor( override fun getFont(fontResId: Int): Typeface? { return runCatching { ResourcesCompat.getFont(context, fontResId) }.getOrNull() } + + override fun getOfKinSuffix(): String { + return getString(R.string.core_ofKin) + } + + override fun getKinSuffix(): String { + return getString(R.string.core_kin) + } } \ No newline at end of file diff --git a/common/resources/src/main/java/com/getcode/util/resources/ResourceHelper.kt b/ui/resources/src/main/java/com/getcode/util/resources/ResourceHelper.kt similarity index 88% rename from common/resources/src/main/java/com/getcode/util/resources/ResourceHelper.kt rename to ui/resources/src/main/java/com/getcode/util/resources/ResourceHelper.kt index 4c904e38b..84591cf71 100644 --- a/common/resources/src/main/java/com/getcode/util/resources/ResourceHelper.kt +++ b/ui/resources/src/main/java/com/getcode/util/resources/ResourceHelper.kt @@ -9,6 +9,7 @@ import androidx.annotation.FontRes import androidx.annotation.PluralsRes import androidx.annotation.RawRes import androidx.annotation.StringRes +import androidx.compose.runtime.staticCompositionLocalOf import java.io.File interface ResourceHelper { @@ -37,6 +38,9 @@ interface ResourceHelper { fun getIdentifier(name: String, type: ResourceType): Int? fun getFont(@FontRes fontResId: Int): Typeface? + + fun getOfKinSuffix(): String + fun getKinSuffix(): String } sealed interface ResourceType { @@ -49,4 +53,4 @@ sealed interface ResourceType { } } -val x: String = "" \ No newline at end of file +val LocalResources = staticCompositionLocalOf { null } \ No newline at end of file diff --git a/common/resources/src/main/java/com/getcode/util/resources/icons/ImageVector.kt b/ui/resources/src/main/java/com/getcode/util/resources/icons/ImageVector.kt similarity index 100% rename from common/resources/src/main/java/com/getcode/util/resources/icons/ImageVector.kt rename to ui/resources/src/main/java/com/getcode/util/resources/icons/ImageVector.kt diff --git a/common/resources/src/main/java/com/getcode/util/resources/icons/MessageCircle.kt b/ui/resources/src/main/java/com/getcode/util/resources/icons/MessageCircle.kt similarity index 100% rename from common/resources/src/main/java/com/getcode/util/resources/icons/MessageCircle.kt rename to ui/resources/src/main/java/com/getcode/util/resources/icons/MessageCircle.kt diff --git a/app/src/main/res/drawable-nodpi/ic_bill_globe.webp b/ui/resources/src/main/res/drawable-nodpi/ic_bill_globe.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_bill_globe.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_bill_globe.webp diff --git a/app/src/main/res/drawable-nodpi/ic_bill_grid.webp b/ui/resources/src/main/res/drawable-nodpi/ic_bill_grid.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_bill_grid.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_bill_grid.webp diff --git a/app/src/main/res/drawable-nodpi/ic_bill_hexagons.webp b/ui/resources/src/main/res/drawable-nodpi/ic_bill_hexagons.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_bill_hexagons.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_bill_hexagons.webp diff --git a/app/src/main/res/drawable-nodpi/ic_bill_security_strip.webp b/ui/resources/src/main/res/drawable-nodpi/ic_bill_security_strip.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_bill_security_strip.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_bill_security_strip.webp diff --git a/app/src/main/res/drawable-nodpi/ic_bill_waves.webp b/ui/resources/src/main/res/drawable-nodpi/ic_bill_waves.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_bill_waves.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_bill_waves.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ad.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ad.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ad.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ad.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ae.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ae.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ae.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ae.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_af.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_af.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_af.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_af.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ag.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ag.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ag.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ag.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ai.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ai.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ai.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ai.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_al.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_al.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_al.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_al.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_am.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_am.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_am.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_am.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_an.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_an.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_an.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_an.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ao.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ao.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ao.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ao.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_aq.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_aq.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_aq.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_aq.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ar.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ar.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ar.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ar.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_as.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_as.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_as.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_as.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_at.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_at.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_at.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_at.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_au.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_au.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_au.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_au.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_aw.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_aw.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_aw.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_aw.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ax.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ax.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ax.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ax.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_az.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_az.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_az.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_az.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ba.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ba.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ba.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ba.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_bb.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_bb.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_bb.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_bb.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_bd.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_bd.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_bd.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_bd.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_be.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_be.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_be.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_be.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_bf.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_bf.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_bf.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_bf.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_bg.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_bg.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_bg.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_bg.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_bh.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_bh.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_bh.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_bh.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_bi.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_bi.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_bi.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_bi.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_bj.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_bj.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_bj.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_bj.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_bl.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_bl.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_bl.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_bl.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_bm.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_bm.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_bm.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_bm.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_bn.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_bn.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_bn.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_bn.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_bo.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_bo.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_bo.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_bo.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_bq.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_bq.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_bq.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_bq.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_br.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_br.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_br.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_br.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_bs.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_bs.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_bs.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_bs.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_bt.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_bt.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_bt.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_bt.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_bv.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_bv.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_bv.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_bv.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_bw.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_bw.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_bw.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_bw.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_by.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_by.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_by.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_by.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_bz.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_bz.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_bz.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_bz.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ca.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ca.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ca.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ca.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_cc.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_cc.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_cc.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_cc.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_cd.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_cd.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_cd.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_cd.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_cf.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_cf.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_cf.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_cf.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_cg.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_cg.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_cg.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_cg.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ch.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ch.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ch.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ch.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ci.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ci.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ci.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ci.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ck.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ck.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ck.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ck.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_cl.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_cl.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_cl.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_cl.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_cm.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_cm.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_cm.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_cm.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_cn.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_cn.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_cn.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_cn.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_co.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_co.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_co.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_co.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_cr.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_cr.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_cr.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_cr.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_cu.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_cu.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_cu.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_cu.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_cv.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_cv.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_cv.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_cv.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_cw.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_cw.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_cw.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_cw.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_cx.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_cx.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_cx.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_cx.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_cy.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_cy.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_cy.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_cy.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_cz.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_cz.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_cz.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_cz.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_de.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_de.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_de.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_de.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_dj.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_dj.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_dj.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_dj.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_dk.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_dk.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_dk.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_dk.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_dm.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_dm.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_dm.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_dm.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_do.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_do.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_do.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_do.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_dz.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_dz.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_dz.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_dz.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ec.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ec.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ec.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ec.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ee.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ee.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ee.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ee.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_eg.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_eg.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_eg.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_eg.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_eh.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_eh.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_eh.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_eh.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_eo.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_eo.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_eo.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_eo.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_er.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_er.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_er.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_er.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_es.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_es.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_es.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_es.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_et.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_et.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_et.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_et.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_eu.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_eu.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_eu.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_eu.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_fi.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_fi.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_fi.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_fi.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_fj.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_fj.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_fj.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_fj.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_fk.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_fk.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_fk.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_fk.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_fm.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_fm.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_fm.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_fm.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_fo.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_fo.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_fo.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_fo.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_fr.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_fr.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_fr.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_fr.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ga.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ga.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ga.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ga.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_gb.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_gb.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_gb.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_gb.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_gd.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_gd.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_gd.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_gd.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ge.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ge.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ge.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ge.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_gf.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_gf.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_gf.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_gf.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_gg.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_gg.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_gg.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_gg.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_gh.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_gh.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_gh.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_gh.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_gi.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_gi.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_gi.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_gi.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_gl.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_gl.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_gl.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_gl.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_gm.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_gm.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_gm.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_gm.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_gn.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_gn.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_gn.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_gn.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_gp.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_gp.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_gp.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_gp.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_gq.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_gq.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_gq.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_gq.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_gr.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_gr.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_gr.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_gr.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_gs.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_gs.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_gs.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_gs.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_gt.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_gt.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_gt.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_gt.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_gu.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_gu.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_gu.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_gu.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_gw.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_gw.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_gw.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_gw.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_gy.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_gy.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_gy.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_gy.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_hk.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_hk.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_hk.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_hk.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_hm.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_hm.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_hm.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_hm.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_hn.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_hn.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_hn.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_hn.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_hr.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_hr.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_hr.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_hr.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ht.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ht.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ht.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ht.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_hu.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_hu.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_hu.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_hu.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_id.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_id.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_id.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_id.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ie.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ie.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ie.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ie.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_il.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_il.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_il.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_il.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_im.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_im.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_im.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_im.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_in.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_in.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_in.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_in.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_io.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_io.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_io.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_io.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_iq.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_iq.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_iq.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_iq.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ir.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ir.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ir.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ir.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_is.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_is.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_is.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_is.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_it.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_it.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_it.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_it.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_je.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_je.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_je.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_je.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_jm.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_jm.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_jm.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_jm.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_jo.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_jo.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_jo.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_jo.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_jp.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_jp.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_jp.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_jp.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ke.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ke.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ke.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ke.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_kg.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_kg.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_kg.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_kg.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_kh.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_kh.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_kh.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_kh.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ki.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ki.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ki.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ki.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_km.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_km.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_km.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_km.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_kn.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_kn.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_kn.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_kn.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_kp.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_kp.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_kp.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_kp.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_kr.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_kr.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_kr.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_kr.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_kw.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_kw.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_kw.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_kw.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ky.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ky.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ky.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ky.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_kz.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_kz.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_kz.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_kz.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_la.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_la.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_la.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_la.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_lb.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_lb.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_lb.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_lb.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_lc.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_lc.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_lc.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_lc.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_li.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_li.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_li.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_li.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_lk.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_lk.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_lk.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_lk.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_lr.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_lr.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_lr.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_lr.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ls.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ls.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ls.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ls.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_lt.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_lt.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_lt.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_lt.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_lu.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_lu.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_lu.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_lu.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_lv.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_lv.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_lv.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_lv.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ly.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ly.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ly.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ly.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ma.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ma.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ma.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ma.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_mc.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_mc.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_mc.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_mc.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_md.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_md.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_md.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_md.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_me.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_me.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_me.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_me.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_mf.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_mf.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_mf.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_mf.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_mg.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_mg.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_mg.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_mg.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_mh.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_mh.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_mh.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_mh.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_mk.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_mk.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_mk.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_mk.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ml.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ml.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ml.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ml.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_mm.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_mm.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_mm.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_mm.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_mn.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_mn.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_mn.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_mn.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_mo.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_mo.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_mo.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_mo.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_mp.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_mp.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_mp.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_mp.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_mq.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_mq.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_mq.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_mq.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_mr.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_mr.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_mr.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_mr.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ms.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ms.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ms.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ms.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_mt.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_mt.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_mt.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_mt.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_mu.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_mu.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_mu.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_mu.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_mv.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_mv.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_mv.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_mv.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_mw.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_mw.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_mw.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_mw.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_mx.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_mx.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_mx.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_mx.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_my.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_my.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_my.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_my.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_mz.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_mz.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_mz.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_mz.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_na.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_na.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_na.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_na.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_nc.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_nc.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_nc.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_nc.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ne.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ne.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ne.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ne.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_nf.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_nf.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_nf.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_nf.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ng.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ng.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ng.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ng.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ni.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ni.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ni.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ni.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_nl.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_nl.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_nl.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_nl.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_no.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_no.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_no.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_no.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_np.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_np.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_np.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_np.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_nr.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_nr.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_nr.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_nr.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_nu.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_nu.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_nu.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_nu.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_nz.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_nz.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_nz.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_nz.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_om.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_om.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_om.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_om.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_pa.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_pa.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_pa.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_pa.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_pe.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_pe.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_pe.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_pe.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_pf.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_pf.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_pf.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_pf.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_pg.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_pg.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_pg.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_pg.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ph.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ph.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ph.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ph.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_pk.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_pk.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_pk.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_pk.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_pl.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_pl.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_pl.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_pl.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_pm.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_pm.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_pm.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_pm.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_pn.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_pn.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_pn.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_pn.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_pr.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_pr.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_pr.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_pr.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ps.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ps.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ps.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ps.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_pt.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_pt.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_pt.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_pt.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_pw.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_pw.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_pw.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_pw.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_py.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_py.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_py.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_py.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_qa.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_qa.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_qa.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_qa.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_re.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_re.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_re.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_re.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ro.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ro.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ro.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ro.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_rs.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_rs.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_rs.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_rs.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ru.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ru.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ru.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ru.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_rw.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_rw.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_rw.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_rw.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_sa.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_sa.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_sa.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_sa.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_sb.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_sb.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_sb.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_sb.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_sc.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_sc.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_sc.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_sc.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_sd.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_sd.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_sd.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_sd.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_se.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_se.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_se.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_se.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_sg.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_sg.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_sg.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_sg.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_sh.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_sh.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_sh.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_sh.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_si.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_si.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_si.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_si.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_sj.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_sj.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_sj.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_sj.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_sk.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_sk.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_sk.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_sk.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_sl.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_sl.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_sl.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_sl.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_sm.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_sm.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_sm.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_sm.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_sn.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_sn.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_sn.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_sn.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_so.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_so.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_so.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_so.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_sr.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_sr.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_sr.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_sr.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ss.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ss.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ss.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ss.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_st.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_st.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_st.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_st.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_sv.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_sv.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_sv.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_sv.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_sx.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_sx.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_sx.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_sx.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_sy.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_sy.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_sy.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_sy.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_sz.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_sz.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_sz.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_sz.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_tc.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_tc.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_tc.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_tc.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_td.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_td.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_td.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_td.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_tf.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_tf.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_tf.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_tf.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_tg.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_tg.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_tg.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_tg.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_th.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_th.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_th.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_th.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_tj.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_tj.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_tj.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_tj.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_tk.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_tk.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_tk.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_tk.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_tl.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_tl.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_tl.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_tl.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_tm.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_tm.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_tm.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_tm.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_tn.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_tn.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_tn.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_tn.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_to.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_to.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_to.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_to.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_tr.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_tr.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_tr.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_tr.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_tt.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_tt.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_tt.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_tt.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_tv.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_tv.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_tv.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_tv.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_tw.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_tw.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_tw.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_tw.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_tz.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_tz.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_tz.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_tz.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ua.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ua.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ua.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ua.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ug.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ug.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ug.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ug.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_um.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_um.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_um.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_um.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_us.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_us.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_us.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_us.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_uy.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_uy.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_uy.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_uy.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_uz.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_uz.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_uz.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_uz.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_va.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_va.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_va.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_va.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_vc.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_vc.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_vc.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_vc.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ve.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ve.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ve.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ve.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_vg.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_vg.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_vg.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_vg.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_vi.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_vi.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_vi.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_vi.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_vn.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_vn.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_vn.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_vn.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_vu.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_vu.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_vu.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_vu.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_wf.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_wf.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_wf.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_wf.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ws.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ws.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ws.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ws.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_ye.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_ye.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_ye.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_ye.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_yt.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_yt.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_yt.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_yt.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_za.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_za.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_za.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_za.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_zm.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_zm.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_zm.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_zm.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_zw.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_zw.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_zw.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_zw.webp diff --git a/app/src/main/res/drawable-nodpi/ic_flag_zz.webp b/ui/resources/src/main/res/drawable-nodpi/ic_flag_zz.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_flag_zz.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_flag_zz.webp diff --git a/app/src/main/res/drawable-nodpi/ic_placeholder_user.webp b/ui/resources/src/main/res/drawable-nodpi/ic_placeholder_user.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/ic_placeholder_user.webp rename to ui/resources/src/main/res/drawable-nodpi/ic_placeholder_user.webp diff --git a/app/src/main/res/drawable-nodpi/video_sell_kin_2x.webp b/ui/resources/src/main/res/drawable-nodpi/video_sell_kin_2x.webp similarity index 100% rename from app/src/main/res/drawable-nodpi/video_sell_kin_2x.webp rename to ui/resources/src/main/res/drawable-nodpi/video_sell_kin_2x.webp diff --git a/app/src/main/res/drawable/ic_android_icon.xml b/ui/resources/src/main/res/drawable/ic_android_icon.xml similarity index 100% rename from app/src/main/res/drawable/ic_android_icon.xml rename to ui/resources/src/main/res/drawable/ic_android_icon.xml diff --git a/app/src/main/res/drawable/ic_apple_icon.xml b/ui/resources/src/main/res/drawable/ic_apple_icon.xml similarity index 100% rename from app/src/main/res/drawable/ic_apple_icon.xml rename to ui/resources/src/main/res/drawable/ic_apple_icon.xml diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/ui/resources/src/main/res/drawable/ic_arrow_back.xml similarity index 100% rename from app/src/main/res/drawable/ic_arrow_back.xml rename to ui/resources/src/main/res/drawable/ic_arrow_back.xml diff --git a/app/src/main/res/drawable/ic_bill_close.xml b/ui/resources/src/main/res/drawable/ic_bill_close.xml similarity index 100% rename from app/src/main/res/drawable/ic_bill_close.xml rename to ui/resources/src/main/res/drawable/ic_bill_close.xml diff --git a/app/src/main/res/drawable/ic_chat.xml b/ui/resources/src/main/res/drawable/ic_chat.xml similarity index 100% rename from app/src/main/res/drawable/ic_chat.xml rename to ui/resources/src/main/res/drawable/ic_chat.xml diff --git a/common/resources/src/main/res/drawable/ic_check.xml b/ui/resources/src/main/res/drawable/ic_check.xml similarity index 100% rename from common/resources/src/main/res/drawable/ic_check.xml rename to ui/resources/src/main/res/drawable/ic_check.xml diff --git a/app/src/main/res/drawable/ic_check_white.xml b/ui/resources/src/main/res/drawable/ic_check_white.xml similarity index 100% rename from app/src/main/res/drawable/ic_check_white.xml rename to ui/resources/src/main/res/drawable/ic_check_white.xml diff --git a/app/src/main/res/drawable/ic_checked_blue.xml b/ui/resources/src/main/res/drawable/ic_checked_blue.xml similarity index 100% rename from app/src/main/res/drawable/ic_checked_blue.xml rename to ui/resources/src/main/res/drawable/ic_checked_blue.xml diff --git a/app/src/main/res/drawable/ic_checked_green.xml b/ui/resources/src/main/res/drawable/ic_checked_green.xml similarity index 100% rename from app/src/main/res/drawable/ic_checked_green.xml rename to ui/resources/src/main/res/drawable/ic_checked_green.xml diff --git a/common/resources/src/main/res/drawable/ic_chevron_left.xml b/ui/resources/src/main/res/drawable/ic_chevron_left.xml similarity index 100% rename from common/resources/src/main/res/drawable/ic_chevron_left.xml rename to ui/resources/src/main/res/drawable/ic_chevron_left.xml diff --git a/common/resources/src/main/res/drawable/ic_chevron_right.xml b/ui/resources/src/main/res/drawable/ic_chevron_right.xml similarity index 100% rename from common/resources/src/main/res/drawable/ic_chevron_right.xml rename to ui/resources/src/main/res/drawable/ic_chevron_right.xml diff --git a/ui/resources/src/main/res/drawable/ic_crown.xml b/ui/resources/src/main/res/drawable/ic_crown.xml new file mode 100644 index 000000000..63e15c78f --- /dev/null +++ b/ui/resources/src/main/res/drawable/ic_crown.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_dropdown.xml b/ui/resources/src/main/res/drawable/ic_dropdown.xml similarity index 100% rename from app/src/main/res/drawable/ic_dropdown.xml rename to ui/resources/src/main/res/drawable/ic_dropdown.xml diff --git a/app/src/main/res/drawable/ic_exclamation_octagon_fill.xml b/ui/resources/src/main/res/drawable/ic_exclamation_octagon_fill.xml similarity index 100% rename from app/src/main/res/drawable/ic_exclamation_octagon_fill.xml rename to ui/resources/src/main/res/drawable/ic_exclamation_octagon_fill.xml diff --git a/app/src/main/res/drawable/ic_kin_brand.xml b/ui/resources/src/main/res/drawable/ic_kin_brand.xml similarity index 100% rename from app/src/main/res/drawable/ic_kin_brand.xml rename to ui/resources/src/main/res/drawable/ic_kin_brand.xml diff --git a/app/src/main/res/drawable/ic_kin_red.xml b/ui/resources/src/main/res/drawable/ic_kin_red.xml similarity index 100% rename from app/src/main/res/drawable/ic_kin_red.xml rename to ui/resources/src/main/res/drawable/ic_kin_red.xml diff --git a/app/src/main/res/drawable/ic_kin_white.xml b/ui/resources/src/main/res/drawable/ic_kin_white.xml similarity index 100% rename from app/src/main/res/drawable/ic_kin_white.xml rename to ui/resources/src/main/res/drawable/ic_kin_white.xml diff --git a/ui/resources/src/main/res/drawable/ic_kin_white_outline.xml b/ui/resources/src/main/res/drawable/ic_kin_white_outline.xml new file mode 100644 index 000000000..295890c43 --- /dev/null +++ b/ui/resources/src/main/res/drawable/ic_kin_white_outline.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_kin_white_small.xml b/ui/resources/src/main/res/drawable/ic_kin_white_small.xml similarity index 100% rename from app/src/main/res/drawable/ic_kin_white_small.xml rename to ui/resources/src/main/res/drawable/ic_kin_white_small.xml diff --git a/app/src/main/res/drawable/ic_remote_send.xml b/ui/resources/src/main/res/drawable/ic_remote_send.xml similarity index 100% rename from app/src/main/res/drawable/ic_remote_send.xml rename to ui/resources/src/main/res/drawable/ic_remote_send.xml diff --git a/app/src/main/res/drawable/ic_settings_outline.xml b/ui/resources/src/main/res/drawable/ic_settings_outline.xml similarity index 100% rename from app/src/main/res/drawable/ic_settings_outline.xml rename to ui/resources/src/main/res/drawable/ic_settings_outline.xml diff --git a/app/src/main/res/drawable/ic_twitter_x.xml b/ui/resources/src/main/res/drawable/ic_twitter_x.xml similarity index 100% rename from app/src/main/res/drawable/ic_twitter_x.xml rename to ui/resources/src/main/res/drawable/ic_twitter_x.xml diff --git a/app/src/main/res/drawable/ic_wifi_slash.xml b/ui/resources/src/main/res/drawable/ic_wifi_slash.xml similarity index 100% rename from app/src/main/res/drawable/ic_wifi_slash.xml rename to ui/resources/src/main/res/drawable/ic_wifi_slash.xml diff --git a/app/src/main/res/drawable/ic_x_octagon_fill.xml b/ui/resources/src/main/res/drawable/ic_x_octagon_fill.xml similarity index 100% rename from app/src/main/res/drawable/ic_x_octagon_fill.xml rename to ui/resources/src/main/res/drawable/ic_x_octagon_fill.xml diff --git a/app/src/main/res/values/strings-localized.xml b/ui/resources/src/main/res/values/strings-localized.xml similarity index 99% rename from app/src/main/res/values/strings-localized.xml rename to ui/resources/src/main/res/values/strings-localized.xml index 223d8159c..ecce64dfe 100644 --- a/app/src/main/res/values/strings-localized.xml +++ b/ui/resources/src/main/res/values/strings-localized.xml @@ -385,4 +385,7 @@ Withdrew Your Access Key Tap the Code logo to share the app download link + + Blocked message + diff --git a/ui/resources/src/main/res/values/strings.xml b/ui/resources/src/main/res/values/strings.xml new file mode 100644 index 000000000..596d567b9 --- /dev/null +++ b/ui/resources/src/main/res/values/strings.xml @@ -0,0 +1,25 @@ + + + Payment Failed + This payment request could not be paid at this time. Please try again later. + + Insufficient Balance + You don\’t have enough Kin to complete this payment + + + ⬢ %1$s + + Reply + Give Tip + Copy Message + Delete + Block User + Block User + Unblock User + Remove %1$s + Report %1$s + Report + Mute User + Make a Speaker + Remove as Speaker + \ No newline at end of file diff --git a/ui/theme/.gitignore b/ui/theme/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/ui/theme/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/common/theme/build.gradle.kts b/ui/theme/build.gradle.kts similarity index 88% rename from common/theme/build.gradle.kts rename to ui/theme/build.gradle.kts index 99256d449..763add26b 100644 --- a/common/theme/build.gradle.kts +++ b/ui/theme/build.gradle.kts @@ -16,8 +16,6 @@ android { kotlinOptions { jvmTarget = Versions.java freeCompilerArgs += listOf( - "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", - "-Xuse-experimental=kotlinx.coroutines.FlowPreview", "-opt-in=kotlin.ExperimentalUnsignedTypes", "-opt-in=kotlin.RequiresOptIn" ) @@ -55,5 +53,11 @@ dependencies { implementation(Libs.compose_ui_tools_preview) implementation(Libs.compose_material) implementation(Libs.compose_accompanist) + implementation(Libs.timber) + + implementation(Libs.androidx_appcompat) + implementation(Libs.androidx_core) + implementation(Libs.androidx_activity) + implementation(Libs.androidx_navigation_fragment) } diff --git a/common/theme/src/main/kotlin/com/getcode/theme/Color.kt b/ui/theme/src/main/kotlin/com/getcode/theme/Color.kt similarity index 88% rename from common/theme/src/main/kotlin/com/getcode/theme/Color.kt rename to ui/theme/src/main/kotlin/com/getcode/theme/Color.kt index 05932f5c8..85f3f8779 100644 --- a/common/theme/src/main/kotlin/com/getcode/theme/Color.kt +++ b/ui/theme/src/main/kotlin/com/getcode/theme/Color.kt @@ -3,7 +3,7 @@ package com.getcode.theme import androidx.compose.foundation.text.selection.TextSelectionColors import androidx.compose.ui.graphics.Color -val Brand = Color(0xff0F0C1F) +val Brand = Color(0xFF0F0C1F) val BrandLight = Color(0xFF7379A0) val BrandSubtle = Color(0xFF565C86) val BrandMuted = Color(0xFF45464E) @@ -11,8 +11,9 @@ val BrandDark = Color(0xFF1F1A34) val BrandAction = Color(0xFF212121) val BrandOverlay = Color(0xBF1E1E1E) val BrandAccent = Color(0xFF443091) +val BrandIndicator = Color(0xFF31BB00) +val BrandSlideToConfirm = Color(0xFF11142A) -val Brand01 = Color(0xFF130F27) val White = Color(0xffffffff) val White50 = Color(0x80FFFFFF) val White10 = Color(0x1AFFFFFF) @@ -25,22 +26,19 @@ val Transparent = Color(0x00FFFFFF) val Gray50 = Color(0x803C3C3C) val DashEffect = Color(0xFF303137) -val TextMain = White -val TextSecondary = BrandLight -val TextError = Color(0xFFDD8484) - val Alert = Color(0xFFFF8383) val Warning = Color(0xFFf1ab1f) val Success = Color(0xFF87D300) val Error = Color(0xFFA42D2D) -val SystemGreen = Color(0xFF04C759) +val TextMain = White +val TextSecondary = BrandLight +val TextError = Alert -val ChatOutgoing = Color(0xFF443091) +val SystemGreen = Color(0xFF04C759) val TopNotification = Color(0xFF4f49ce) val TopNeutral = Color(0xFF747474) -val TopSuccess = Brand val textSelectionColors = TextSelectionColors( handleColor = White, diff --git a/common/theme/src/main/kotlin/com/getcode/theme/Dimens.kt b/ui/theme/src/main/kotlin/com/getcode/theme/Dimens.kt similarity index 91% rename from common/theme/src/main/kotlin/com/getcode/theme/Dimens.kt rename to ui/theme/src/main/kotlin/com/getcode/theme/Dimens.kt index 7c679ad6a..0059250bc 100644 --- a/common/theme/src/main/kotlin/com/getcode/theme/Dimens.kt +++ b/ui/theme/src/main/kotlin/com/getcode/theme/Dimens.kt @@ -56,7 +56,10 @@ fun calculateDimensions( 35.dp, 37.5.dp, 40.dp, - 42.5.dp + 42.5.dp, + 45.dp, + 47.5.dp, + 50.dp ) WindowSizeClass.NORMAL -> GridDimensionSet( @@ -76,7 +79,10 @@ fun calculateDimensions( 70.dp, 75.dp, 80.dp, - 85.dp + 85.dp, + 90.dp, + 95.dp, + 100.dp ) WindowSizeClass.MEDIUM, @@ -98,7 +104,10 @@ fun calculateDimensions( 105.dp, 112.5.dp, 120.dp, - 127.5.dp + 127.5.dp, + 135.dp, + 142.5.dp, + 150.dp ) }, widthWindowSizeClass = widthSizeClass, @@ -136,7 +145,10 @@ private val staticGridPreset = x14 = 56.dp, x15 = 60.dp, x16 = 64.dp, - x17 = 68.dp + x17 = 68.dp, + x18 = 72.dp, + x19 = 76.dp, + x20 = 80.dp, ) /** Fixed 5pt grid **/ @@ -158,7 +170,10 @@ private val static5GridPreset = x14 = 70.dp, x15 = 75.dp, x16 = 80.dp, - x17 = 85.dp + x17 = 85.dp, + x18 = 90.dp, + x19 = 95.dp, + x20 = 100.dp ) class Dimensions( @@ -206,4 +221,7 @@ data class GridDimensionSet( val x15: Dp, val x16: Dp, val x17: Dp, + val x18: Dp, + val x19: Dp, + val x20: Dp, ) diff --git a/ui/theme/src/main/kotlin/com/getcode/theme/DropShadow.kt b/ui/theme/src/main/kotlin/com/getcode/theme/DropShadow.kt new file mode 100644 index 000000000..5a32981cb --- /dev/null +++ b/ui/theme/src/main/kotlin/com/getcode/theme/DropShadow.kt @@ -0,0 +1,69 @@ +package com.getcode.theme + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +data class DropShadow( + val color: Color = Color.Black, + val offsetX: Dp = 0.dp, + val offsetY: Dp = 0.dp, + val blurRadius: Dp = 0.dp, + val spread: Dp = 0.dp +) + +fun Paint.makeBlur(shadowBlurRadius: Float) { + this.asFrameworkPaint().apply { + maskFilter = + android.graphics.BlurMaskFilter( + shadowBlurRadius, android.graphics.BlurMaskFilter.Blur.NORMAL) + } +} + +fun Modifier.dropShadow(shadow: DropShadow): Modifier { + return this.then( + with(shadow) { Modifier.dropShadow(color, offsetX, offsetY, blurRadius, spread) }) +} + fun Modifier.dropShadow( + color: Color = Color.Black, + offsetX: Dp = 0.dp, + offsetY: Dp = 0.dp, + blurRadius: Dp = 0.dp, + spread: Dp = 0.dp +): Modifier { + return this.then( + Modifier.drawBehind { + val shadowOffsetX = offsetX.toPx() + val shadowOffsetY = offsetY.toPx() + val shadowBlurRadius = blurRadius.toPx() + val shadowSpread = spread.toPx() + val rectLeft = shadowOffsetX - shadowSpread + val rectTop = shadowOffsetY - shadowSpread + val rectRight = size.width + shadowOffsetX + shadowSpread + val rectBottom = size.height + shadowOffsetY + shadowSpread + val cornerRadius = spread.toPx() + + drawIntoCanvas { canvas -> + val paint = + Paint().apply { + this.color = color + this.makeBlur(shadowBlurRadius) + } + translate(left = shadowOffsetX, top = shadowOffsetY) { + canvas.drawRoundRect( + left = rectLeft, + top = rectTop, + right = rectRight, + bottom = rectBottom, + radiusX = cornerRadius, + radiusY = cornerRadius, + paint) + } + } + }) +} \ No newline at end of file diff --git a/common/theme/src/main/kotlin/com/getcode/theme/Shape.kt b/ui/theme/src/main/kotlin/com/getcode/theme/Shape.kt similarity index 100% rename from common/theme/src/main/kotlin/com/getcode/theme/Shape.kt rename to ui/theme/src/main/kotlin/com/getcode/theme/Shape.kt diff --git a/common/theme/src/main/kotlin/com/getcode/theme/Theme.kt b/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt similarity index 74% rename from common/theme/src/main/kotlin/com/getcode/theme/Theme.kt rename to ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt index 88dcc7e3d..27c91b4ba 100644 --- a/common/theme/src/main/kotlin/com/getcode/theme/Theme.kt +++ b/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt @@ -17,32 +17,40 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import com.google.accompanist.systemuicontroller.rememberSystemUiController - -private val DarkColorPalette = CodeColors( +internal val CodeDefaultColorScheme = ColorScheme( brand = Brand, brandLight = BrandLight, brandSubtle = BrandSubtle, brandMuted = BrandMuted, brandDark = BrandDark, brandOverlay = BrandOverlay, + brandContainer = Brand, secondary = BrandAccent, + tertiary = BrandAccent, + indicator = BrandIndicator, action = Gray50, onAction = White, background = Brand, onBackground = White, surface = Brand, + surfaceVariant = BrandDark, onSurface = White, error = Error, errorText = TextError, + success = Success, textMain = TextMain, - textSecondary = TextSecondary + textSecondary = TextSecondary, + divider = White10, + dividerVariant = White05, + trackColor = BrandSlideToConfirm ) @Composable -fun CodeTheme( +fun DesignSystem( + colorScheme: ColorScheme = CodeDefaultColorScheme, + typography: CodeTypography = codeTypography, content: @Composable () -> Unit ) { - val colors = DarkColorPalette val sysUiController = rememberSystemUiController() SideEffect { @@ -52,8 +60,8 @@ fun CodeTheme( val dimensions = calculateDimensions() - ProvideCodeColors(colors) { - ProvideCodeTypography(typography = codeTypography) { + ProvideColorScheme(colorScheme) { + ProvideTypography(typography = typography) { ProvideDimens(dimensions = dimensions) { MaterialTheme( colors = debugColors(), @@ -71,7 +79,7 @@ fun CodeTheme( } object CodeTheme { - val colors: CodeColors + val colors: ColorScheme @Composable get() = LocalCodeColors.current val dimens: Dimensions @Composable get() = LocalDimens.current @@ -82,24 +90,32 @@ object CodeTheme { } @Stable -class CodeColors( +class ColorScheme( brand: Color, brandLight: Color, brandSubtle: Color, brandMuted: Color, brandDark: Color, brandOverlay: Color, + brandContainer: Color, secondary: Color, + tertiary: Color, + indicator: Color, action: Color, onAction: Color, background: Color, onBackground: Color, surface: Color, + surfaceVariant: Color, onSurface: Color, + divider: Color, + dividerVariant: Color, error: Color, errorText: Color, + success: Color, textMain: Color, textSecondary: Color, + trackColor: Color, ) { var brand by mutableStateOf(brand) private set @@ -113,73 +129,105 @@ class CodeColors( private set var brandOverlay by mutableStateOf(brandOverlay) private set + var brandContainer by mutableStateOf(brandContainer) + private set var background by mutableStateOf(background) private set var onBackground by mutableStateOf(onBackground) private set var surface by mutableStateOf(surface) private set + var surfaceVariant by mutableStateOf(surfaceVariant) + private set var onSurface by mutableStateOf(onSurface) private set var error by mutableStateOf(error) private set var errorText by mutableStateOf(errorText) private set + var success by mutableStateOf(success) + private set var textMain by mutableStateOf(textMain) private set var textSecondary by mutableStateOf(textSecondary) private set var secondary by mutableStateOf(secondary) private set + var tertiary by mutableStateOf(tertiary) + private set + var indicator by mutableStateOf(indicator) + private set var action by mutableStateOf(action) private set var onAction by mutableStateOf(onAction) private set + var divider by mutableStateOf(divider) + private set + var dividerVariant by mutableStateOf(dividerVariant) + private set + var trackColor by mutableStateOf(trackColor) + private set - fun update(other: CodeColors) { + fun update(other: ColorScheme) { brand = other.brand brandLight = other.brandLight brandSubtle = other.brandSubtle brandMuted = other.brandMuted brandDark = other.brandDark brandOverlay = other.brandOverlay + brandContainer = other.brandContainer background = other.background onBackground = other.onBackground surface = other.surface + surfaceVariant = other.surfaceVariant onSurface = other.onSurface error = other.error errorText = other.errorText + success = other.success textMain = other.textMain textSecondary = other.textSecondary secondary = other.secondary + tertiary = other.tertiary + indicator = other.indicator action = other.action onAction = other.onAction + divider = other.divider + dividerVariant = other.dividerVariant + trackColor = other.trackColor } - fun copy(): CodeColors = CodeColors( + fun copy(): ColorScheme = ColorScheme( brand = brand, brandLight = brandLight, brandSubtle = brandSubtle, brandMuted = brandMuted, brandDark = brandDark, brandOverlay = brandOverlay, + brandContainer = brandContainer, background = background, onBackground = onBackground, surface = surface, + surfaceVariant = surfaceVariant, onSurface = onSurface, error = error, errorText = errorText, + success = success, textMain = textMain, textSecondary = textSecondary, secondary = secondary, + tertiary = tertiary, + indicator = indicator, action = action, - onAction = onAction + onAction = onAction, + divider = divider, + dividerVariant = dividerVariant, + trackColor = trackColor ) } @Composable -fun ProvideCodeColors( - colors: CodeColors, +fun ProvideColorScheme( + colors: ColorScheme, content: @Composable () -> Unit ) { val colorPalette = remember { colors.copy() } @@ -187,13 +235,13 @@ fun ProvideCodeColors( CompositionLocalProvider(LocalCodeColors provides colorPalette, content = content) } -val LocalCodeColors = staticCompositionLocalOf { +val LocalCodeColors = staticCompositionLocalOf { error("No ColorPalette provided") } /** * A Material [Colors] implementation which sets all colors to [debugColor] to discourage usage of - * [MaterialTheme.colors] in preference to [CodeTheme.colors]. + * [MaterialTheme.colors] in preference to [DesignSystem.colors]. */ fun debugColors( darkTheme: Boolean = true, @@ -218,7 +266,7 @@ fun debugColors( fun inputColors( textColor: Color = Color.White, disabledTextColor: Color = Color.White, - borderColor: Color = BrandLight, + borderColor: Color = CodeTheme.colors.brandLight, unfocusedBorderColor: Color = borderColor, backgroundColor: Color = White05, placeholderColor: Color = White50, diff --git a/common/theme/src/main/kotlin/com/getcode/theme/Type.kt b/ui/theme/src/main/kotlin/com/getcode/theme/Type.kt similarity index 98% rename from common/theme/src/main/kotlin/com/getcode/theme/Type.kt rename to ui/theme/src/main/kotlin/com/getcode/theme/Type.kt index 5881bb905..b490fda86 100644 --- a/common/theme/src/main/kotlin/com/getcode/theme/Type.kt +++ b/ui/theme/src/main/kotlin/com/getcode/theme/Type.kt @@ -51,14 +51,14 @@ val LocalCodeTypography = staticCompositionLocalOf { } @Composable -fun ProvideCodeTypography( +fun ProvideTypography( typography: CodeTypography, content: @Composable () -> Unit ) { CompositionLocalProvider(LocalCodeTypography provides typography, content = content) } -internal val codeTypography = CodeTypography( +val codeTypography = CodeTypography( displayLarge = TextStyle( fontFamily = Avenir, fontSize = 55.sp, diff --git a/common/theme/src/main/kotlin/com/getcode/theme/WindowSizeClass.kt b/ui/theme/src/main/kotlin/com/getcode/theme/WindowSizeClass.kt similarity index 100% rename from common/theme/src/main/kotlin/com/getcode/theme/WindowSizeClass.kt rename to ui/theme/src/main/kotlin/com/getcode/theme/WindowSizeClass.kt diff --git a/app/src/main/java/com/getcode/ui/utils/View.kt b/ui/theme/src/main/kotlin/com/getcode/ui/utils/View.kt similarity index 63% rename from app/src/main/java/com/getcode/ui/utils/View.kt rename to ui/theme/src/main/kotlin/com/getcode/ui/utils/View.kt index 50c9d3bdb..39c39f342 100644 --- a/app/src/main/java/com/getcode/ui/utils/View.kt +++ b/ui/theme/src/main/kotlin/com/getcode/ui/utils/View.kt @@ -1,24 +1,16 @@ package com.getcode.ui.utils -import android.app.Activity import android.content.Context import android.content.ContextWrapper -import android.content.Intent import android.content.res.Resources -import android.os.Process.killProcess -import android.os.Process.myPid import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.sp import androidx.fragment.app.FragmentActivity -import com.getcode.BuildConfig -import com.getcode.view.MainActivity -import com.google.firebase.crashlytics.FirebaseCrashlytics import java.util.* import kotlin.concurrent.timerTask -import kotlin.system.exitProcess fun Int.dipToPixels() = (Resources.getSystem().displayMetrics.density * this).toInt() @@ -53,25 +45,6 @@ val Float.toDp get() = this / Resources.getSystem().displayMetrics.density val Int.toPx get() = (this * Resources.getSystem().displayMetrics.density).toInt() val Int.toDp get() = (this / Resources.getSystem().displayMetrics.density).toInt() -fun Activity.handleUncaughtException() { - val crashedKey = "isCrashed" - if (intent.getBooleanExtra(crashedKey, false)) return - Thread.setDefaultUncaughtExceptionHandler { _, throwable -> - if (BuildConfig.DEBUG) throw throwable - - FirebaseCrashlytics.getInstance().recordException(throwable) - - val intent = Intent(this, MainActivity::class.java).apply { - putExtra(crashedKey, true) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) - } - startActivity(intent) - finish() - killProcess(myPid()) - exitProcess(2) - } -} - fun withDelay(delay: Long, block: () -> Unit) { Timer().schedule(timerTask { block() } , delay) } \ No newline at end of file diff --git a/common/theme/src/main/kotlin/com/getcode/view/shapes/TriangleCutShape.kt b/ui/theme/src/main/kotlin/com/getcode/view/shapes/TriangleCutShape.kt similarity index 100% rename from common/theme/src/main/kotlin/com/getcode/view/shapes/TriangleCutShape.kt rename to ui/theme/src/main/kotlin/com/getcode/view/shapes/TriangleCutShape.kt diff --git a/common/theme/src/main/res/font/avenir_next_demi.otf b/ui/theme/src/main/res/font/avenir_next_demi.otf similarity index 100% rename from common/theme/src/main/res/font/avenir_next_demi.otf rename to ui/theme/src/main/res/font/avenir_next_demi.otf diff --git a/common/theme/src/main/res/font/avenir_next_medium.otf b/ui/theme/src/main/res/font/avenir_next_medium.otf similarity index 100% rename from common/theme/src/main/res/font/avenir_next_medium.otf rename to ui/theme/src/main/res/font/avenir_next_medium.otf diff --git a/common/theme/src/main/res/font/avenir_next_regular.otf b/ui/theme/src/main/res/font/avenir_next_regular.otf similarity index 100% rename from common/theme/src/main/res/font/avenir_next_regular.otf rename to ui/theme/src/main/res/font/avenir_next_regular.otf diff --git a/common/theme/src/main/res/font/roboto_mono_variable.ttf b/ui/theme/src/main/res/font/roboto_mono_variable.ttf similarity index 100% rename from common/theme/src/main/res/font/roboto_mono_variable.ttf rename to ui/theme/src/main/res/font/roboto_mono_variable.ttf diff --git a/vendor/tipkit/tipkit-m2/build.gradle.kts b/vendor/tipkit/tipkit-m2/build.gradle.kts index 65af29165..039c20dd9 100644 --- a/vendor/tipkit/tipkit-m2/build.gradle.kts +++ b/vendor/tipkit/tipkit-m2/build.gradle.kts @@ -16,8 +16,6 @@ android { kotlinOptions { jvmTarget = Versions.java freeCompilerArgs += listOf( - "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", - "-Xuse-experimental=kotlinx.coroutines.FlowPreview", "-opt-in=kotlin.ExperimentalUnsignedTypes", "-opt-in=kotlin.RequiresOptIn" ) @@ -49,9 +47,10 @@ android { dependencies { api(project(":vendor:tipkit:tipkit")) - implementation(project(":common:theme")) + implementation(project(":ui:theme")) implementation(platform(Libs.compose_bom)) implementation(Libs.compose_ui) implementation(Libs.compose_ui_graphics) implementation(Libs.compose_material) + implementation(Libs.compose_materialIconsCore) } diff --git a/vendor/tipkit/tipkit-m2/src/main/kotlin/dev/bmcreations/tipkit/TipScaffold.kt b/vendor/tipkit/tipkit-m2/src/main/kotlin/dev/bmcreations/tipkit/TipScaffold.kt index 90018d7f5..a8f7c41db 100644 --- a/vendor/tipkit/tipkit-m2/src/main/kotlin/dev/bmcreations/tipkit/TipScaffold.kt +++ b/vendor/tipkit/tipkit-m2/src/main/kotlin/dev/bmcreations/tipkit/TipScaffold.kt @@ -130,7 +130,7 @@ object TipDefaults { modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1) ) { - Divider(color = BrandLight) + Divider(color = CodeTheme.colors.brandLight) tip.actions().onEach { Text( modifier = Modifier diff --git a/vendor/tipkit/tipkit/build.gradle.kts b/vendor/tipkit/tipkit/build.gradle.kts index 844e31063..2425db035 100644 --- a/vendor/tipkit/tipkit/build.gradle.kts +++ b/vendor/tipkit/tipkit/build.gradle.kts @@ -17,8 +17,6 @@ android { kotlinOptions { jvmTarget = Versions.java freeCompilerArgs += listOf( - "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", - "-Xuse-experimental=kotlinx.coroutines.FlowPreview", "-opt-in=kotlin.ExperimentalUnsignedTypes", "-opt-in=kotlin.RequiresOptIn" )