From c78d24a45815b556be64198b7579a6aed4b9092a Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 24 Feb 2022 21:55:56 +0000 Subject: [PATCH] initial commit --- .gitignore | 13 + .idea/.gitignore | 3 + .idea/.name | 1 + .idea/codeStyles/Project.xml | 127 +++++ .idea/codeStyles/codeStyleConfig.xml | 5 + README.md | 48 ++ app/build.gradle | 83 +++ app/proguard/app.pro | 8 + app/proguard/clear.pro | 1 + app/proguard/olm.pro | 1 + app/proguard/serializationx.pro | 16 + app/src/debug/AndroidManifest.xml | 11 + .../res/values/com_crashlytics_build_id.xml | 4 + app/src/debug/res/values/values.xml | 10 + app/src/main/AndroidManifest.xml | 28 + .../app/dapk/st/SharedPreferencesDelegate.kt | 39 ++ .../app/dapk/st/SmallTalkApplication.kt | 76 +++ .../kotlin/app/dapk/st/graph/AppModule.kt | 405 ++++++++++++++ .../res/drawable/ic_launcher_background.xml | 74 +++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 4 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 4 + .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes app/src/main/res/raw/keep.xml | 2 + app/src/main/res/values/colors.xml | 10 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 7 + app/src/release/res/values/values.xml | 10 + build.gradle | 141 +++++ core/build.gradle | 11 + .../main/kotlin/app/dapk/st/core/BuildMeta.kt | 5 + .../app/dapk/st/core/CoroutineDispatchers.kt | 15 + .../kotlin/app/dapk/st/core/HeliumLogger.kt | 28 + core/src/main/kotlin/app/dapk/st/core/Lce.kt | 8 + .../kotlin/app/dapk/st/core/ModuleProvider.kt | 10 + .../kotlin/app/dapk/st/core/SingletonFlows.kt | 47 ++ .../dapk/st/core/extensions/ErrorTracker.kt | 15 + .../dapk/st/core/extensions/FlowExtensions.kt | 24 + .../st/core/extensions/GlobalExtensions.kt | 23 + .../dapk/st/core/extensions/LceExtensions.kt | 11 + .../dapk/st/core/extensions/ListExtensions.kt | 3 + .../app/dapk/st/core/extensions/Scope.kt | 30 ++ .../kotlin/fake/FakeErrorTracker.kt | 6 + .../kotlin/test/MockkExtensions.kt | 15 + .../kotlin/test/TestSharedFlow.kt | 29 + dependencies.gradle | 147 ++++++ design-library/build.gradle | 7 + design-library/src/main/AndroidManifest.xml | 2 + .../st/design/components/ComposeExtensions.kt | 9 + .../app/dapk/st/design/components/Icon.kt | 79 +++ .../dapk/st/design/components/OverflowMenu.kt | 34 ++ .../app/dapk/st/design/components/Spider.kt | 55 ++ .../app/dapk/st/design/components/Theme.kt | 71 +++ .../app/dapk/st/design/components/Toolbar.kt | 32 ++ .../app/dapk/st/design/components/itemRow.kt | 60 +++ domains/android/core/build.gradle | 6 + .../android/core/src/main/AndroidManifest.xml | 2 + .../app/dapk/st/core/ActivityExtensions.kt | 19 + .../app/dapk/st/core/ComposeExtensions.kt | 53 ++ .../app/dapk/st/core/ContextExtensions.kt | 6 + .../app/dapk/st/core/CoreAndroidModule.kt | 9 + .../kotlin/app/dapk/st/core/DapkActivity.kt | 33 ++ .../kotlin/app/dapk/st/core/DapkViewModel.kt | 22 + .../app/dapk/st/core/components/Components.kt | 29 + domains/android/imageloader/build.gradle | 6 + .../imageloader/src/main/AndroidManifest.xml | 2 + .../app/dapk/st/imageloader/ImageLoader.kt | 62 +++ .../dapk/st/imageloader/ImageLoaderModule.kt | 16 + domains/android/push/build.gradle | 8 + .../android/push/src/main/AndroidManifest.xml | 2 + .../st/push/FirebaseMessagingExtensions.kt | 18 + .../kotlin/app/dapk/st/push/PushModule.kt | 16 + .../push/RegisterFirebasePushTokenUseCase.kt | 27 + domains/android/tracking/build.gradle | 10 + .../tracking/src/main/AndroidManifest.xml | 2 + .../st/tracking/CrashlyticsCrashTracker.kt | 19 + .../app/dapk/st/tracking/TrackingModule.kt | 23 + domains/android/work/build.gradle | 6 + .../android/work/src/main/AndroidManifest.xml | 11 + .../kotlin/app/dapk/st/work/TaskRunner.kt | 22 + .../app/dapk/st/work/WorkAndroidService.kt | 72 +++ .../kotlin/app/dapk/st/work/WorkModule.kt | 12 + .../kotlin/app/dapk/st/work/WorkScheduler.kt | 9 + .../st/work/WorkSchedulingJobScheduler.kt | 34 ++ domains/olm-stub/build.gradle | 7 + .../main/java/org/matrix/olm/OlmAccount.java | 85 +++ .../java/org/matrix/olm/OlmException.java | 70 +++ .../matrix/olm/OlmInboundGroupSession.java | 59 +++ .../main/java/org/matrix/olm/OlmManager.java | 14 + .../main/java/org/matrix/olm/OlmMessage.java | 12 + .../matrix/olm/OlmOutboundGroupSession.java | 43 ++ .../src/main/java/org/matrix/olm/OlmSAS.java | 32 ++ .../main/java/org/matrix/olm/OlmSession.java | 63 +++ .../main/java/org/matrix/olm/OlmUtility.java | 41 ++ domains/olm/build.gradle | 15 + .../app/dapk/st/olm/DefaultSasSession.kt | 53 ++ .../app/dapk/st/olm/DeviceKeyFactory.kt | 39 ++ .../kotlin/app/dapk/st/olm/OlmExtensions.kt | 10 + .../app/dapk/st/olm/OlmPersistenceWrapper.kt | 69 +++ .../main/kotlin/app/dapk/st/olm/OlmStore.kt | 21 + .../main/kotlin/app/dapk/st/olm/OlmWrapper.kt | 375 +++++++++++++ domains/store/build.gradle | 25 + .../dapk/st/domain/CredentialsPreferences.kt | 24 + .../app/dapk/st/domain/DatabaseDropper.kt | 5 + .../app/dapk/st/domain/DevicePersistence.kt | 99 ++++ .../app/dapk/st/domain/FilterPreferences.kt | 16 + .../app/dapk/st/domain/MemberPersistence.kt | 38 ++ .../app/dapk/st/domain/OlmPersistence.kt | 85 +++ .../kotlin/app/dapk/st/domain/Preferences.kt | 9 + .../kotlin/app/dapk/st/domain/StoreCleaner.kt | 5 + .../kotlin/app/dapk/st/domain/StoreModule.kt | 57 ++ .../dapk/st/domain/SyncTokenPreferences.kt | 24 + .../st/domain/eventlog/EventLogPersistence.kt | 62 +++ .../domain/localecho/LocalEchoPersistence.kt | 109 ++++ .../st/domain/profile/ProfilePersistence.kt | 51 ++ .../st/domain/sync/OverviewPersistence.kt | 82 +++ .../dapk/st/domain/sync/RoomPersistence.kt | 135 +++++ .../sqldelight/app/dapk/db/model/Crypto.sq | 61 +++ .../sqldelight/app/dapk/db/model/Device.sq | 47 ++ .../app/dapk/db/model/EventLogger.sq | 28 + .../app/dapk/db/model/InviteState.sq | 12 + .../sqldelight/app/dapk/db/model/LocalEcho.sq | 18 + .../app/dapk/db/model/OverviewState.sq | 21 + .../sqldelight/app/dapk/db/model/RoomEvent.sq | 33 ++ .../app/dapk/db/model/RoomMember.sq | 15 + .../app/dapk/db/model/UnreadEvent.sq | 18 + external/jolm.jar | Bin 0 -> 171501 bytes features/directory/build.gradle | 12 + .../directory/src/main/AndroidManifest.xml | 2 + .../st/directory/DirectoryListingScreen.kt | 258 +++++++++ .../app/dapk/st/directory/DirectoryModule.kt | 32 ++ .../app/dapk/st/directory/DirectoryState.kt | 19 + .../app/dapk/st/directory/DirectoryUseCase.kt | 87 +++ .../dapk/st/directory/DirectoryViewModel.kt | 40 ++ .../app/dapk/st/directory/ShortcutHandler.kt | 42 ++ features/home/build.gradle | 14 + features/home/src/main/AndroidManifest.xml | 8 + .../kotlin/app/dapk/st/home/HomeModule.kt | 18 + .../kotlin/app/dapk/st/home/HomeScreen.kt | 84 +++ .../main/kotlin/app/dapk/st/home/HomeState.kt | 23 + .../kotlin/app/dapk/st/home/HomeViewModel.kt | 57 ++ .../kotlin/app/dapk/st/home/MainActivity.kt | 26 + features/login/build.gradle | 10 + features/login/src/main/AndroidManifest.xml | 2 + .../kotlin/app/dapk/st/login/LoginModule.kt | 20 + .../kotlin/app/dapk/st/login/LoginScreen.kt | 141 +++++ .../kotlin/app/dapk/st/login/LoginState.kt | 13 + .../app/dapk/st/login/LoginViewModel.kt | 52 ++ features/messenger/build.gradle | 13 + .../messenger/src/main/AndroidManifest.xml | 15 + .../messenger/MergeWithLocalEchosUseCase.kt | 65 +++ .../dapk/st/messenger/MessengerActivity.kt | 57 ++ .../app/dapk/st/messenger/MessengerModule.kt | 21 + .../app/dapk/st/messenger/MessengerScreen.kt | 490 +++++++++++++++++ .../app/dapk/st/messenger/MessengerState.kt | 20 + .../dapk/st/messenger/MessengerViewModel.kt | 95 ++++ .../app/dapk/st/messenger/TimelineUseCase.kt | 51 ++ .../roomsettings/RoomSettingsActivity.kt | 51 ++ .../roomsettings/RoomSettingsScreen.kt | 17 + features/navigator/build.gradle | 6 + .../navigator/src/main/AndroidManifest.xml | 2 + .../kotlin/app/dapk/st/navigator/Navigator.kt | 63 +++ features/notifications/build.gradle | 15 + .../src/main/AndroidManifest.xml | 14 + .../st/notifications/NotificationDisplayer.kt | 30 ++ .../st/notifications/NotificationsModule.kt | 35 ++ .../st/notifications/NotificationsUseCase.kt | 176 +++++++ .../st/notifications/PushAndroidService.kt | 81 +++ .../notifications/RoomGroupMessageCreator.kt | 124 +++++ features/profile/build.gradle | 11 + features/profile/src/main/AndroidManifest.xml | 2 + .../app/dapk/st/profile/ProfileModule.kt | 14 + .../app/dapk/st/profile/ProfileScreen.kt | 102 ++++ .../app/dapk/st/profile/ProfileState.kt | 14 + .../app/dapk/st/profile/ProfileViewModel.kt | 30 ++ features/settings/build.gradle | 11 + .../settings/src/main/AndroidManifest.xml | 7 + .../app/dapk/st/settings/SettingsActivity.kt | 30 ++ .../app/dapk/st/settings/SettingsModule.kt | 32 ++ .../app/dapk/st/settings/SettingsScreen.kt | 226 ++++++++ .../app/dapk/st/settings/SettingsState.kt | 57 ++ .../app/dapk/st/settings/SettingsViewModel.kt | 117 +++++ .../dapk/st/settings/UriFilenameResolver.kt | 29 + .../settings/eventlogger/EventLogActivity.kt | 28 + .../st/settings/eventlogger/EventLogScreen.kt | 114 ++++ .../settings/eventlogger/EventLoggerState.kt | 15 + .../eventlogger/EventLoggerViewModel.kt | 51 ++ features/verification/build.gradle | 8 + .../verification/src/main/AndroidManifest.xml | 8 + .../st/verification/VerificationActivity.kt | 27 + .../st/verification/VerificationModule.kt | 14 + .../st/verification/VerificationScreen.kt | 29 + .../dapk/st/verification/VerificationState.kt | 8 + .../st/verification/VerificationViewModel.kt | 21 + gradle.properties | 23 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59536 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 234 +++++++++ gradlew.bat | 89 ++++ matrix/common/build.gradle | 13 + .../dapk/st/matrix/common/AlgorithmName.kt | 7 + .../app/dapk/st/matrix/common/AvatarUrl.kt | 7 + .../app/dapk/st/matrix/common/CipherText.kt | 7 + .../dapk/st/matrix/common/CredentialsStore.kt | 10 + .../app/dapk/st/matrix/common/Curve25519.kt | 7 + .../dapk/st/matrix/common/DecryptionResult.kt | 6 + .../app/dapk/st/matrix/common/DeviceId.kt | 7 + .../app/dapk/st/matrix/common/Ed25519.kt | 7 + .../matrix/common/EncryptedMessageContent.kt | 22 + .../app/dapk/st/matrix/common/EventId.kt | 7 + .../app/dapk/st/matrix/common/EventType.kt | 18 + .../dapk/st/matrix/common/HomeServerUrl.kt | 7 + .../st/matrix/common/JsonCanonicalizer.kt | 28 + .../app/dapk/st/matrix/common/JsonString.kt | 4 + .../app/dapk/st/matrix/common/MatrixLogger.kt | 30 ++ .../kotlin/app/dapk/st/matrix/common/MxUrl.kt | 19 + .../app/dapk/st/matrix/common/RoomId.kt | 7 + .../app/dapk/st/matrix/common/RoomMember.kt | 11 + .../dapk/st/matrix/common/ServerKeyCount.kt | 7 + .../app/dapk/st/matrix/common/SessionId.kt | 7 + .../dapk/st/matrix/common/SharedRoomKey.kt | 9 + .../app/dapk/st/matrix/common/SignedJson.kt | 4 + .../app/dapk/st/matrix/common/SyncToken.kt | 7 + .../dapk/st/matrix/common/UserCredentials.kt | 25 + .../app/dapk/st/matrix/common/UserId.kt | 7 + .../common/extensions/JsonStringExtensions.kt | 19 + .../st/matrix/common/JsonCanonicalizerTest.kt | 108 ++++ .../kotlin/fake/FakeCredentialsStore.kt | 10 + .../kotlin/fake/FakeMatrixLogger.kt | 9 + .../fixture/DeviceCredentialsFixture.kt | 13 + .../kotlin/fixture/ModelFixtures.kt | 14 + .../kotlin/fixture/RoomMemberFixture.kt | 11 + .../kotlin/fixture/UserCredentialsFixture.kt | 13 + matrix/matrix-http-ktor/build.gradle | 13 + .../http/ktor/KtorMatrixHttpClientFactory.kt | 34 ++ .../ktor/internal/KtorMatrixHttpClient.kt | 106 ++++ matrix/matrix-http/build.gradle | 11 + .../app/dapk/st/matrix/http/HttpExtensions.kt | 9 + .../app/dapk/st/matrix/http/JsonExtensions.kt | 51 ++ .../dapk/st/matrix/http/MatrixHttpClient.kt | 56 ++ .../NullableJsonTransformingSerializer.kt | 26 + .../dapk/st/matrix/http/RequestExtensions.kt | 7 + .../kotlin/fake/FakeMatrixHttpClient.kt | 11 + matrix/matrix/build.gradle | 9 + .../kotlin/app/dapk/st/matrix/MatrixClient.kt | 68 +++ .../app/dapk/st/matrix/ServiceInstaller.kt | 60 +++ matrix/services/auth/build.gradle | 1 + .../app/dapk/st/matrix/auth/AuthService.kt | 32 ++ .../st/matrix/auth/internal/AuthRequest.kt | 101 ++++ .../auth/internal/DefaultAuthService.kt | 29 + .../internal/FetchWellKnownUseCaseImpl.kt | 19 + .../st/matrix/auth/internal/LoginUseCase.kt | 60 +++ .../matrix/auth/internal/RegisterUseCase.kt | 71 +++ matrix/services/crypto/build.gradle | 17 + .../dapk/st/matrix/crypto/CryptoService.kt | 161 ++++++ .../kotlin/app/dapk/st/matrix/crypto/Olm.kt | 88 ++++ .../crypto/internal/AccountCryptoUseCase.kt | 21 + .../crypto/internal/DefaultCryptoService.kt | 55 ++ .../EncryptMessageWithMegolmUseCase.kt | 33 ++ .../internal/FetchMegolmSessionUseCase.kt | 48 ++ .../crypto/internal/MessageToEncrypt.kt | 6 + .../st/matrix/crypto/internal/OlmCrypto.kt | 53 ++ .../internal/OneTimeKeyUploaderUseCase.kt | 42 ++ .../internal/RegisterOlmSessionUseCase.kt | 55 ++ .../matrix/crypto/internal/RoomKeyImporter.kt | 178 +++++++ .../crypto/internal/ShareRoomKeyUseCase.kt | 65 +++ .../crypto/internal/VerificationHandler.kt | 212 ++++++++ .../internal/EncryptMegolmUseCaseTest.kt | 59 +++ .../internal/FetchAccountCryptoUseCaseTest.kt | 48 ++ .../internal/FetchMegolmSessionUseCaseTest.kt | 63 +++ ...beCreateAndUploadOneTimeKeysUseCaseTest.kt | 65 +++ .../internal/RegisterOlmSessionUseCaseTest.kt | 89 ++++ .../internal/ShareRoomKeyUseCaseTest.kt | 85 +++ .../FakeFetchAccountCryptoUseCase.kt | 12 + .../FakeFetchMegolmSessionUseCase.kt | 13 + .../FakeRegisterOlmSessionUseCase.kt | 15 + .../internalfake/FakeShareRoomKeyUseCase.kt | 23 + .../src/testFixtures/kotlin/fake/FakeOlm.kt | 69 +++ .../kotlin/fixture/CryptoSessionFixtures.kt | 31 ++ .../kotlin/fixture/FakeRoomMembersProvider.kt | 11 + matrix/services/device/build.gradle | 9 + .../dapk/st/matrix/device/DeviceService.kt | 140 +++++ .../device/internal/DefaultDeviceService.kt | 162 ++++++ .../device/internal/EncyptionRequests.kt | 88 ++++ .../kotlin/fake/FakeDeviceService.kt | 23 + .../fixture/ClaimKeysResponseFixture.kt | 11 + .../kotlin/fixture/DeviceKeysFixutre.kt | 15 + .../kotlin/fixture/KeyClaimFixture.kt | 12 + matrix/services/message/build.gradle | 1 + .../dapk/st/matrix/message/ApiSendResponse.kt | 10 + .../st/matrix/message/BackgroundScheduler.kt | 9 + .../st/matrix/message/MessageEncrypter.kt | 26 + .../dapk/st/matrix/message/MessageService.kt | 118 +++++ .../app/dapk/st/matrix/message/Store.kt | 14 + .../message/internal/DefaultMessageService.kt | 72 +++ .../internal/SendEventMessageUseCase.kt | 27 + .../message/internal/SendMessageUseCase.kt | 40 ++ .../st/matrix/message/internal/SendRequest.kt | 36 ++ matrix/services/profile/build.gradle | 5 + .../app/dapk/st/matrix/room/ProfileService.kt | 38 ++ .../app/dapk/st/matrix/room/ProfileStore.kt | 8 + .../room/internal/DefaultProfileService.kt | 51 ++ matrix/services/push/build.gradle | 1 + .../app/dapk/st/matrix/push/PushService.kt | 48 ++ .../push/internal/DefaultPushService.kt | 20 + .../st/matrix/push/internal/PushRequest.kt | 12 + .../push/internal/RegisterPushUseCase.kt | 39 ++ matrix/services/room/build.gradle | 1 + .../app/dapk/st/matrix/room/RoomService.kt | 55 ++ .../room/internal/DefaultRoomService.kt | 125 +++++ .../st/matrix/room/internal/RoomMembers.kt | 49 ++ matrix/services/sync/build.gradle | 13 + .../app/dapk/st/matrix/sync/OverviewState.kt | 35 ++ .../app/dapk/st/matrix/sync/RoomState.kt | 113 ++++ .../kotlin/app/dapk/st/matrix/sync/Store.kt | 60 +++ .../app/dapk/st/matrix/sync/SyncService.kt | 142 +++++ .../sync/internal/DefaultSyncService.kt | 118 +++++ .../st/matrix/sync/internal/FlowIterator.kt | 26 + .../sync/internal/filter/FilterRequest.kt | 14 + .../sync/internal/filter/FilterUseCase.kt | 24 + .../overview/ReducedSyncFilterUseCase.kt | 39 ++ .../internal/request/ApiFilterResponse.kt | 41 ++ .../sync/internal/request/ApiSyncResponse.kt | 496 ++++++++++++++++++ .../sync/internal/request/SyncRequest.kt | 19 + .../sync/internal/room/RoomEventsDecrypter.kt | 49 ++ .../sync/internal/room/RoomStateReducer.kt | 28 + .../sync/internal/room/SyncEventDecrypter.kt | 50 ++ .../sync/internal/room/SyncSideEffects.kt | 84 +++ .../internal/sync/EphemeralEventsUseCase.kt | 22 + .../sync/internal/sync/EventLookupUseCase.kt | 30 ++ .../matrix/sync/internal/sync/LookupResult.kt | 22 + .../sync/internal/sync/RoomDataSource.kt | 30 ++ .../sync/internal/sync/RoomEventCreator.kt | 132 +++++ .../internal/sync/RoomOverviewProcessor.kt | 81 +++ .../sync/internal/sync/RoomProcessor.kt | 73 +++ .../sync/internal/sync/RoomRefresher.kt | 35 ++ .../sync/internal/sync/RoomToProcess.kt | 13 + .../matrix/sync/internal/sync/SyncReducer.kt | 71 +++ .../matrix/sync/internal/sync/SyncUseCase.kt | 74 +++ .../internal/sync/TimelineEventsProcessor.kt | 57 ++ .../sync/internal/sync/UnreadEventsUseCase.kt | 52 ++ .../sync/internal/filter/FilterUseCaseTest.kt | 44 ++ .../sync/EphemeralEventsUseCaseTest.kt | 73 +++ .../internal/sync/EventLookupUseCaseTest.kt | 74 +++ .../internal/sync/RoomEventCreatorTest.kt | 329 ++++++++++++ .../sync/internal/sync/RoomRefresherTest.kt | 64 +++ .../sync/TimelineEventsProcessorTest.kt | 96 ++++ .../internal/sync/UnreadEventsUseCaseTest.kt | 68 +++ .../kotlin/internalfake/FakeEventLookup.kt | 17 + .../internalfake/FakeRoomEventCreator.kt | 33 ++ .../internalfake/FakeRoomEventsDecrypter.kt | 14 + .../internalfake/FakeSyncEventDecrypter.kt | 14 + .../internalfixture/ApiSyncRoomFixture.kt | 120 +++++ .../kotlin/fake/FakeFilterStore.kt | 13 + .../kotlin/fake/FakeRoomDataSource.kt | 28 + .../kotlin/fake/FakeRoomMembersService.kt | 19 + .../testFixtures/kotlin/fake/FakeRoomStore.kt | 30 ++ .../kotlin/fixture/RoomEventFixture.kt | 37 ++ .../kotlin/fixture/RoomOverviewFixture.kt | 25 + .../kotlin/fixture/SyncServiceFixtures.kt | 2 + settings.gradle | 47 ++ test-harness/build.gradle | 35 ++ test-harness/src/test/kotlin/SmokeTest.kt | 172 ++++++ .../io/ktor/client/engine/java/AJava.kt | 45 ++ test-harness/src/test/kotlin/test/Test.kt | 143 +++++ .../src/test/kotlin/test/TestExtensions.kt | 14 + .../src/test/kotlin/test/TestMatrix.kt | 273 ++++++++++ .../src/test/kotlin/test/TestPersistence.kt | 72 +++ .../test/kotlin/test/impl/InMemoryDatabase.kt | 22 + .../kotlin/test/impl/InMemoryPreferences.kt | 18 + .../test/kotlin/test/impl/InstantScheduler.kt | 21 + .../kotlin/test/impl/PrintingErrorTracking.kt | 10 + .../src/test/resources/element-keys.txt | 176 +++++++ tools/benchmark/benchmark.profile | 8 + tools/benchmark/run_benchmark.sh | 15 + tools/check-size.sh | 13 + tools/coverage.gradle | 93 ++++ tools/debug.keystore | Bin 0 -> 2763 bytes tools/device-spec.json | 8 + 383 files changed, 16781 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/.name create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 README.md create mode 100644 app/build.gradle create mode 100644 app/proguard/app.pro create mode 100644 app/proguard/clear.pro create mode 100644 app/proguard/olm.pro create mode 100644 app/proguard/serializationx.pro create mode 100644 app/src/debug/AndroidManifest.xml create mode 100644 app/src/debug/res/values/com_crashlytics_build_id.xml create mode 100644 app/src/debug/res/values/values.xml create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/kotlin/app/dapk/st/SharedPreferencesDelegate.kt create mode 100644 app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt create mode 100644 app/src/main/kotlin/app/dapk/st/graph/AppModule.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/raw/keep.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/release/res/values/values.xml create mode 100644 build.gradle create mode 100644 core/build.gradle create mode 100644 core/src/main/kotlin/app/dapk/st/core/BuildMeta.kt create mode 100644 core/src/main/kotlin/app/dapk/st/core/CoroutineDispatchers.kt create mode 100644 core/src/main/kotlin/app/dapk/st/core/HeliumLogger.kt create mode 100644 core/src/main/kotlin/app/dapk/st/core/Lce.kt create mode 100644 core/src/main/kotlin/app/dapk/st/core/ModuleProvider.kt create mode 100644 core/src/main/kotlin/app/dapk/st/core/SingletonFlows.kt create mode 100644 core/src/main/kotlin/app/dapk/st/core/extensions/ErrorTracker.kt create mode 100644 core/src/main/kotlin/app/dapk/st/core/extensions/FlowExtensions.kt create mode 100644 core/src/main/kotlin/app/dapk/st/core/extensions/GlobalExtensions.kt create mode 100644 core/src/main/kotlin/app/dapk/st/core/extensions/LceExtensions.kt create mode 100644 core/src/main/kotlin/app/dapk/st/core/extensions/ListExtensions.kt create mode 100644 core/src/main/kotlin/app/dapk/st/core/extensions/Scope.kt create mode 100644 core/src/testFixtures/kotlin/fake/FakeErrorTracker.kt create mode 100644 core/src/testFixtures/kotlin/test/MockkExtensions.kt create mode 100644 core/src/testFixtures/kotlin/test/TestSharedFlow.kt create mode 100644 dependencies.gradle create mode 100644 design-library/build.gradle create mode 100644 design-library/src/main/AndroidManifest.xml create mode 100644 design-library/src/main/kotlin/app/dapk/st/design/components/ComposeExtensions.kt create mode 100644 design-library/src/main/kotlin/app/dapk/st/design/components/Icon.kt create mode 100644 design-library/src/main/kotlin/app/dapk/st/design/components/OverflowMenu.kt create mode 100644 design-library/src/main/kotlin/app/dapk/st/design/components/Spider.kt create mode 100644 design-library/src/main/kotlin/app/dapk/st/design/components/Theme.kt create mode 100644 design-library/src/main/kotlin/app/dapk/st/design/components/Toolbar.kt create mode 100644 design-library/src/main/kotlin/app/dapk/st/design/components/itemRow.kt create mode 100644 domains/android/core/build.gradle create mode 100644 domains/android/core/src/main/AndroidManifest.xml create mode 100644 domains/android/core/src/main/kotlin/app/dapk/st/core/ActivityExtensions.kt create mode 100644 domains/android/core/src/main/kotlin/app/dapk/st/core/ComposeExtensions.kt create mode 100644 domains/android/core/src/main/kotlin/app/dapk/st/core/ContextExtensions.kt create mode 100644 domains/android/core/src/main/kotlin/app/dapk/st/core/CoreAndroidModule.kt create mode 100644 domains/android/core/src/main/kotlin/app/dapk/st/core/DapkActivity.kt create mode 100644 domains/android/core/src/main/kotlin/app/dapk/st/core/DapkViewModel.kt create mode 100644 domains/android/core/src/main/kotlin/app/dapk/st/core/components/Components.kt create mode 100644 domains/android/imageloader/build.gradle create mode 100644 domains/android/imageloader/src/main/AndroidManifest.xml create mode 100644 domains/android/imageloader/src/main/kotlin/app/dapk/st/imageloader/ImageLoader.kt create mode 100644 domains/android/imageloader/src/main/kotlin/app/dapk/st/imageloader/ImageLoaderModule.kt create mode 100644 domains/android/push/build.gradle create mode 100644 domains/android/push/src/main/AndroidManifest.xml create mode 100644 domains/android/push/src/main/kotlin/app/dapk/st/push/FirebaseMessagingExtensions.kt create mode 100644 domains/android/push/src/main/kotlin/app/dapk/st/push/PushModule.kt create mode 100644 domains/android/push/src/main/kotlin/app/dapk/st/push/RegisterFirebasePushTokenUseCase.kt create mode 100644 domains/android/tracking/build.gradle create mode 100644 domains/android/tracking/src/main/AndroidManifest.xml create mode 100644 domains/android/tracking/src/main/kotlin/app/dapk/st/tracking/CrashlyticsCrashTracker.kt create mode 100644 domains/android/tracking/src/main/kotlin/app/dapk/st/tracking/TrackingModule.kt create mode 100644 domains/android/work/build.gradle create mode 100644 domains/android/work/src/main/AndroidManifest.xml create mode 100644 domains/android/work/src/main/kotlin/app/dapk/st/work/TaskRunner.kt create mode 100644 domains/android/work/src/main/kotlin/app/dapk/st/work/WorkAndroidService.kt create mode 100644 domains/android/work/src/main/kotlin/app/dapk/st/work/WorkModule.kt create mode 100644 domains/android/work/src/main/kotlin/app/dapk/st/work/WorkScheduler.kt create mode 100644 domains/android/work/src/main/kotlin/app/dapk/st/work/WorkSchedulingJobScheduler.kt create mode 100644 domains/olm-stub/build.gradle create mode 100644 domains/olm-stub/src/main/java/org/matrix/olm/OlmAccount.java create mode 100644 domains/olm-stub/src/main/java/org/matrix/olm/OlmException.java create mode 100644 domains/olm-stub/src/main/java/org/matrix/olm/OlmInboundGroupSession.java create mode 100644 domains/olm-stub/src/main/java/org/matrix/olm/OlmManager.java create mode 100644 domains/olm-stub/src/main/java/org/matrix/olm/OlmMessage.java create mode 100644 domains/olm-stub/src/main/java/org/matrix/olm/OlmOutboundGroupSession.java create mode 100644 domains/olm-stub/src/main/java/org/matrix/olm/OlmSAS.java create mode 100644 domains/olm-stub/src/main/java/org/matrix/olm/OlmSession.java create mode 100644 domains/olm-stub/src/main/java/org/matrix/olm/OlmUtility.java create mode 100644 domains/olm/build.gradle create mode 100644 domains/olm/src/main/kotlin/app/dapk/st/olm/DefaultSasSession.kt create mode 100644 domains/olm/src/main/kotlin/app/dapk/st/olm/DeviceKeyFactory.kt create mode 100644 domains/olm/src/main/kotlin/app/dapk/st/olm/OlmExtensions.kt create mode 100644 domains/olm/src/main/kotlin/app/dapk/st/olm/OlmPersistenceWrapper.kt create mode 100644 domains/olm/src/main/kotlin/app/dapk/st/olm/OlmStore.kt create mode 100644 domains/olm/src/main/kotlin/app/dapk/st/olm/OlmWrapper.kt create mode 100644 domains/store/build.gradle create mode 100644 domains/store/src/main/kotlin/app/dapk/st/domain/CredentialsPreferences.kt create mode 100644 domains/store/src/main/kotlin/app/dapk/st/domain/DatabaseDropper.kt create mode 100644 domains/store/src/main/kotlin/app/dapk/st/domain/DevicePersistence.kt create mode 100644 domains/store/src/main/kotlin/app/dapk/st/domain/FilterPreferences.kt create mode 100644 domains/store/src/main/kotlin/app/dapk/st/domain/MemberPersistence.kt create mode 100644 domains/store/src/main/kotlin/app/dapk/st/domain/OlmPersistence.kt create mode 100644 domains/store/src/main/kotlin/app/dapk/st/domain/Preferences.kt create mode 100644 domains/store/src/main/kotlin/app/dapk/st/domain/StoreCleaner.kt create mode 100644 domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt create mode 100644 domains/store/src/main/kotlin/app/dapk/st/domain/SyncTokenPreferences.kt create mode 100644 domains/store/src/main/kotlin/app/dapk/st/domain/eventlog/EventLogPersistence.kt create mode 100644 domains/store/src/main/kotlin/app/dapk/st/domain/localecho/LocalEchoPersistence.kt create mode 100644 domains/store/src/main/kotlin/app/dapk/st/domain/profile/ProfilePersistence.kt create mode 100644 domains/store/src/main/kotlin/app/dapk/st/domain/sync/OverviewPersistence.kt create mode 100644 domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt create mode 100644 domains/store/src/main/sqldelight/app/dapk/db/model/Crypto.sq create mode 100644 domains/store/src/main/sqldelight/app/dapk/db/model/Device.sq create mode 100644 domains/store/src/main/sqldelight/app/dapk/db/model/EventLogger.sq create mode 100644 domains/store/src/main/sqldelight/app/dapk/db/model/InviteState.sq create mode 100644 domains/store/src/main/sqldelight/app/dapk/db/model/LocalEcho.sq create mode 100644 domains/store/src/main/sqldelight/app/dapk/db/model/OverviewState.sq create mode 100644 domains/store/src/main/sqldelight/app/dapk/db/model/RoomEvent.sq create mode 100644 domains/store/src/main/sqldelight/app/dapk/db/model/RoomMember.sq create mode 100644 domains/store/src/main/sqldelight/app/dapk/db/model/UnreadEvent.sq create mode 100644 external/jolm.jar create mode 100644 features/directory/build.gradle create mode 100644 features/directory/src/main/AndroidManifest.xml create mode 100644 features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryListingScreen.kt create mode 100644 features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt create mode 100644 features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryState.kt create mode 100644 features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryUseCase.kt create mode 100644 features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryViewModel.kt create mode 100644 features/directory/src/main/kotlin/app/dapk/st/directory/ShortcutHandler.kt create mode 100644 features/home/build.gradle create mode 100644 features/home/src/main/AndroidManifest.xml create mode 100644 features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt create mode 100644 features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt create mode 100644 features/home/src/main/kotlin/app/dapk/st/home/HomeState.kt create mode 100644 features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt create mode 100644 features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt create mode 100644 features/login/build.gradle create mode 100644 features/login/src/main/AndroidManifest.xml create mode 100644 features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt create mode 100644 features/login/src/main/kotlin/app/dapk/st/login/LoginScreen.kt create mode 100644 features/login/src/main/kotlin/app/dapk/st/login/LoginState.kt create mode 100644 features/login/src/main/kotlin/app/dapk/st/login/LoginViewModel.kt create mode 100644 features/messenger/build.gradle create mode 100644 features/messenger/src/main/AndroidManifest.xml create mode 100644 features/messenger/src/main/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCase.kt create mode 100644 features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt create mode 100644 features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt create mode 100644 features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt create mode 100644 features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt create mode 100644 features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt create mode 100644 features/messenger/src/main/kotlin/app/dapk/st/messenger/TimelineUseCase.kt create mode 100644 features/messenger/src/main/kotlin/app/dapk/st/messenger/roomsettings/RoomSettingsActivity.kt create mode 100644 features/messenger/src/main/kotlin/app/dapk/st/messenger/roomsettings/RoomSettingsScreen.kt create mode 100644 features/navigator/build.gradle create mode 100644 features/navigator/src/main/AndroidManifest.xml create mode 100644 features/navigator/src/main/kotlin/app/dapk/st/navigator/Navigator.kt create mode 100644 features/notifications/build.gradle create mode 100644 features/notifications/src/main/AndroidManifest.xml create mode 100644 features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationDisplayer.kt create mode 100644 features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt create mode 100644 features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsUseCase.kt create mode 100644 features/notifications/src/main/kotlin/app/dapk/st/notifications/PushAndroidService.kt create mode 100644 features/notifications/src/main/kotlin/app/dapk/st/notifications/RoomGroupMessageCreator.kt create mode 100644 features/profile/build.gradle create mode 100644 features/profile/src/main/AndroidManifest.xml create mode 100644 features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt create mode 100644 features/profile/src/main/kotlin/app/dapk/st/profile/ProfileScreen.kt create mode 100644 features/profile/src/main/kotlin/app/dapk/st/profile/ProfileState.kt create mode 100644 features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt create mode 100644 features/settings/build.gradle create mode 100644 features/settings/src/main/AndroidManifest.xml create mode 100644 features/settings/src/main/kotlin/app/dapk/st/settings/SettingsActivity.kt create mode 100644 features/settings/src/main/kotlin/app/dapk/st/settings/SettingsModule.kt create mode 100644 features/settings/src/main/kotlin/app/dapk/st/settings/SettingsScreen.kt create mode 100644 features/settings/src/main/kotlin/app/dapk/st/settings/SettingsState.kt create mode 100644 features/settings/src/main/kotlin/app/dapk/st/settings/SettingsViewModel.kt create mode 100644 features/settings/src/main/kotlin/app/dapk/st/settings/UriFilenameResolver.kt create mode 100644 features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLogActivity.kt create mode 100644 features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLogScreen.kt create mode 100644 features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLoggerState.kt create mode 100644 features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLoggerViewModel.kt create mode 100644 features/verification/build.gradle create mode 100644 features/verification/src/main/AndroidManifest.xml create mode 100644 features/verification/src/main/kotlin/app/dapk/st/verification/VerificationActivity.kt create mode 100644 features/verification/src/main/kotlin/app/dapk/st/verification/VerificationModule.kt create mode 100644 features/verification/src/main/kotlin/app/dapk/st/verification/VerificationScreen.kt create mode 100644 features/verification/src/main/kotlin/app/dapk/st/verification/VerificationState.kt create mode 100644 features/verification/src/main/kotlin/app/dapk/st/verification/VerificationViewModel.kt create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 matrix/common/build.gradle create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/AlgorithmName.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/AvatarUrl.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/CipherText.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/CredentialsStore.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/Curve25519.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/DecryptionResult.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/DeviceId.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/Ed25519.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/EncryptedMessageContent.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/EventId.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/EventType.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/HomeServerUrl.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/JsonCanonicalizer.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/JsonString.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/MatrixLogger.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/MxUrl.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RoomId.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RoomMember.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/ServerKeyCount.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/SessionId.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/SharedRoomKey.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/SignedJson.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/SyncToken.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/UserCredentials.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/UserId.kt create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/extensions/JsonStringExtensions.kt create mode 100644 matrix/common/src/test/kotlin/app/dapk/st/matrix/common/JsonCanonicalizerTest.kt create mode 100644 matrix/common/src/testFixtures/kotlin/fake/FakeCredentialsStore.kt create mode 100644 matrix/common/src/testFixtures/kotlin/fake/FakeMatrixLogger.kt create mode 100644 matrix/common/src/testFixtures/kotlin/fixture/DeviceCredentialsFixture.kt create mode 100644 matrix/common/src/testFixtures/kotlin/fixture/ModelFixtures.kt create mode 100644 matrix/common/src/testFixtures/kotlin/fixture/RoomMemberFixture.kt create mode 100644 matrix/common/src/testFixtures/kotlin/fixture/UserCredentialsFixture.kt create mode 100644 matrix/matrix-http-ktor/build.gradle create mode 100644 matrix/matrix-http-ktor/src/main/kotlin/app/dapk/st/matrix/http/ktor/KtorMatrixHttpClientFactory.kt create mode 100644 matrix/matrix-http-ktor/src/main/kotlin/app/dapk/st/matrix/http/ktor/internal/KtorMatrixHttpClient.kt create mode 100644 matrix/matrix-http/build.gradle create mode 100644 matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/HttpExtensions.kt create mode 100644 matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/JsonExtensions.kt create mode 100644 matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/MatrixHttpClient.kt create mode 100644 matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/NullableJsonTransformingSerializer.kt create mode 100644 matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/RequestExtensions.kt create mode 100644 matrix/matrix-http/src/testFixtures/kotlin/fake/FakeMatrixHttpClient.kt create mode 100644 matrix/matrix/build.gradle create mode 100644 matrix/matrix/src/main/kotlin/app/dapk/st/matrix/MatrixClient.kt create mode 100644 matrix/matrix/src/main/kotlin/app/dapk/st/matrix/ServiceInstaller.kt create mode 100644 matrix/services/auth/build.gradle create mode 100644 matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/AuthService.kt create mode 100644 matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/AuthRequest.kt create mode 100644 matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/DefaultAuthService.kt create mode 100644 matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/FetchWellKnownUseCaseImpl.kt create mode 100644 matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/LoginUseCase.kt create mode 100644 matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/RegisterUseCase.kt create mode 100644 matrix/services/crypto/build.gradle create mode 100644 matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/CryptoService.kt create mode 100644 matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/Olm.kt create mode 100644 matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/AccountCryptoUseCase.kt create mode 100644 matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/DefaultCryptoService.kt create mode 100644 matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/EncryptMessageWithMegolmUseCase.kt create mode 100644 matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/FetchMegolmSessionUseCase.kt create mode 100644 matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/MessageToEncrypt.kt create mode 100644 matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/OlmCrypto.kt create mode 100644 matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/OneTimeKeyUploaderUseCase.kt create mode 100644 matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/RegisterOlmSessionUseCase.kt create mode 100644 matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/RoomKeyImporter.kt create mode 100644 matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/ShareRoomKeyUseCase.kt create mode 100644 matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/VerificationHandler.kt create mode 100644 matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/EncryptMegolmUseCaseTest.kt create mode 100644 matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/FetchAccountCryptoUseCaseTest.kt create mode 100644 matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/FetchMegolmSessionUseCaseTest.kt create mode 100644 matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/MaybeCreateAndUploadOneTimeKeysUseCaseTest.kt create mode 100644 matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/RegisterOlmSessionUseCaseTest.kt create mode 100644 matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/ShareRoomKeyUseCaseTest.kt create mode 100644 matrix/services/crypto/src/test/kotlin/internalfake/FakeFetchAccountCryptoUseCase.kt create mode 100644 matrix/services/crypto/src/test/kotlin/internalfake/FakeFetchMegolmSessionUseCase.kt create mode 100644 matrix/services/crypto/src/test/kotlin/internalfake/FakeRegisterOlmSessionUseCase.kt create mode 100644 matrix/services/crypto/src/test/kotlin/internalfake/FakeShareRoomKeyUseCase.kt create mode 100644 matrix/services/crypto/src/testFixtures/kotlin/fake/FakeOlm.kt create mode 100644 matrix/services/crypto/src/testFixtures/kotlin/fixture/CryptoSessionFixtures.kt create mode 100644 matrix/services/crypto/src/testFixtures/kotlin/fixture/FakeRoomMembersProvider.kt create mode 100644 matrix/services/device/build.gradle create mode 100644 matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/DeviceService.kt create mode 100644 matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/DefaultDeviceService.kt create mode 100644 matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/EncyptionRequests.kt create mode 100644 matrix/services/device/src/testFixtures/kotlin/fake/FakeDeviceService.kt create mode 100644 matrix/services/device/src/testFixtures/kotlin/fixture/ClaimKeysResponseFixture.kt create mode 100644 matrix/services/device/src/testFixtures/kotlin/fixture/DeviceKeysFixutre.kt create mode 100644 matrix/services/device/src/testFixtures/kotlin/fixture/KeyClaimFixture.kt create mode 100644 matrix/services/message/build.gradle create mode 100644 matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/ApiSendResponse.kt create mode 100644 matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/BackgroundScheduler.kt create mode 100644 matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageEncrypter.kt create mode 100644 matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt create mode 100644 matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/Store.kt create mode 100644 matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt create mode 100644 matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendEventMessageUseCase.kt create mode 100644 matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt create mode 100644 matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt create mode 100644 matrix/services/profile/build.gradle create mode 100644 matrix/services/profile/src/main/kotlin/app/dapk/st/matrix/room/ProfileService.kt create mode 100644 matrix/services/profile/src/main/kotlin/app/dapk/st/matrix/room/ProfileStore.kt create mode 100644 matrix/services/profile/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultProfileService.kt create mode 100644 matrix/services/push/build.gradle create mode 100644 matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/PushService.kt create mode 100644 matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/DefaultPushService.kt create mode 100644 matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/PushRequest.kt create mode 100644 matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/RegisterPushUseCase.kt create mode 100644 matrix/services/room/build.gradle create mode 100644 matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt create mode 100644 matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt create mode 100644 matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/RoomMembers.kt create mode 100644 matrix/services/sync/build.gradle create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/OverviewState.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/RoomState.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/Store.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/FlowIterator.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/filter/FilterRequest.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/filter/FilterUseCase.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/overview/ReducedSyncFilterUseCase.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiFilterResponse.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiSyncResponse.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/SyncRequest.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypter.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomStateReducer.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/SyncEventDecrypter.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/SyncSideEffects.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/EphemeralEventsUseCase.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/EventLookupUseCase.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/LookupResult.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomDataSource.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomOverviewProcessor.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomProcessor.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresher.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomToProcess.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncReducer.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncUseCase.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessor.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsUseCase.kt create mode 100644 matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/filter/FilterUseCaseTest.kt create mode 100644 matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/EphemeralEventsUseCaseTest.kt create mode 100644 matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/EventLookupUseCaseTest.kt create mode 100644 matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt create mode 100644 matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresherTest.kt create mode 100644 matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessorTest.kt create mode 100644 matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsUseCaseTest.kt create mode 100644 matrix/services/sync/src/test/kotlin/internalfake/FakeEventLookup.kt create mode 100644 matrix/services/sync/src/test/kotlin/internalfake/FakeRoomEventCreator.kt create mode 100644 matrix/services/sync/src/test/kotlin/internalfake/FakeRoomEventsDecrypter.kt create mode 100644 matrix/services/sync/src/test/kotlin/internalfake/FakeSyncEventDecrypter.kt create mode 100644 matrix/services/sync/src/test/kotlin/internalfixture/ApiSyncRoomFixture.kt create mode 100644 matrix/services/sync/src/testFixtures/kotlin/fake/FakeFilterStore.kt create mode 100644 matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomDataSource.kt create mode 100644 matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomMembersService.kt create mode 100644 matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomStore.kt create mode 100644 matrix/services/sync/src/testFixtures/kotlin/fixture/RoomEventFixture.kt create mode 100644 matrix/services/sync/src/testFixtures/kotlin/fixture/RoomOverviewFixture.kt create mode 100644 matrix/services/sync/src/testFixtures/kotlin/fixture/SyncServiceFixtures.kt create mode 100644 settings.gradle create mode 100644 test-harness/build.gradle create mode 100644 test-harness/src/test/kotlin/SmokeTest.kt create mode 100644 test-harness/src/test/kotlin/io/ktor/client/engine/java/AJava.kt create mode 100644 test-harness/src/test/kotlin/test/Test.kt create mode 100644 test-harness/src/test/kotlin/test/TestExtensions.kt create mode 100644 test-harness/src/test/kotlin/test/TestMatrix.kt create mode 100644 test-harness/src/test/kotlin/test/TestPersistence.kt create mode 100644 test-harness/src/test/kotlin/test/impl/InMemoryDatabase.kt create mode 100644 test-harness/src/test/kotlin/test/impl/InMemoryPreferences.kt create mode 100644 test-harness/src/test/kotlin/test/impl/InstantScheduler.kt create mode 100644 test-harness/src/test/kotlin/test/impl/PrintingErrorTracking.kt create mode 100644 test-harness/src/test/resources/element-keys.txt create mode 100644 tools/benchmark/benchmark.profile create mode 100755 tools/benchmark/run_benchmark.sh create mode 100755 tools/check-size.sh create mode 100644 tools/coverage.gradle create mode 100644 tools/debug.keystore create mode 100644 tools/device-spec.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b76762 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +.idea/*.xml +.DS_Store +**/build +/captures +.externalNativeBuild +.cxx +local.properties +/benchmark-out \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..facb0ed --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +SmallTalk \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..b5c2c1d --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,127 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f6089b --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# SmallTalk [![codecov](https://codecov.io/gh/ouchadam/small-talk/branch/main/graph/badge.svg?token=ETFSLZ9FCI)](https://codecov.io/gh/ouchadam/small-talk) + +`SmallTalk` is a minimal, modern, friends and family focused Android messenger. Heavily inspired by Whatsapp and Signal, powered by Matrix. + +--- + +Project mantra +- Tiny app size - currently 1.72mb~ when provided via app bundle. +- Focused on reliability and stability. +- Bare-bones feature set. + +##### _*Google play only with automatic crash reporting enabled_ + +--- + +#### Feature list + +- Login with username/password (home servers must serve `${domain}.well-known/matrix/client`) +- Combined Room and DM interface +- End to end encryption +- Message bubbles, supporting text, replies and edits +- Push notifications (DMs always notify, Rooms notify once) +- Importing of E2E room keys from Element clients + +#### Planned + +- Device verification (technically supported but has no UI) +- Invitations (technically supported but has no UI) +- Room history +- Message media +- Cross signing +- Google drive backups +- Markdown subset (bold, italic, blocks) +- Changing user name/avatar +- Room settings and information +- Exporting E2E room keys +- Local search +- Registration + +--- + +#### Technical details + +- Built on Jetpack compose and kotlin multiplatform libraries ktor and sqldelight (although the project is not currently setup to be multiplatform until needed). +- Greenfield matrix SDK implementation, focus on separation, testability and parallelisation. +- Heavily optimised build script, clean _cacheless_ builds are sub 10 seconds with a warmed up gradle daemon. +- Avoids code generation where possible in favour of build speed, this mainly means manual DI. +- A pure kotlin test harness to allow for critical flow assertions [Smoke Tests](https://github.com/ouchadam/small-talk/blob/main/test-harness/src/test/kotlin/SmokeTest.kt), currently Linux x86-64 only. diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..c514057 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,83 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' +} + +applyCommonAndroidParameters(project) +applyCrashlyticsIfRelease(project) + +android { + ndkVersion "25.0.8141415" + defaultConfig { + applicationId "app.dapk.st" + versionCode 1 + versionName "0.0.1-alpha1" + resConfigs "en" + } + + bundle { + abi.enableSplit true + density.enableSplit true + language.enableSplit true + } + + buildTypes { + debug { + matchingFallbacks = ['release'] + signingConfig.storeFile rootProject.file("tools/debug.keystore") + } + release { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard/app.pro', + "proguard/serializationx.pro", + "proguard/olm.pro" + signingConfig = buildTypes.debug.signingConfig + } + } + + packagingOptions { + resources.excludes += "DebugProbesKt.bin" + } +} + +dependencies { + implementation project(":features:home") + implementation project(":features:directory") + implementation project(":features:login") + implementation project(":features:settings") + implementation project(":features:notifications") + implementation project(":features:messenger") + implementation project(":features:profile") + implementation project(":features:navigator") + + implementation project(':domains:store') + implementation project(":domains:android:core") + implementation project(":domains:android:tracking") + implementation project(":domains:android:push") + implementation project(":domains:android:work") + implementation project(":domains:android:imageloader") + implementation project(":domains:olm") + + implementation project(":matrix:matrix") + implementation project(":matrix:matrix-http-ktor") + implementation project(":matrix:services:auth") + implementation project(":matrix:services:sync") + implementation project(":matrix:services:room") + implementation project(":matrix:services:push") + implementation project(":matrix:services:message") + implementation project(":matrix:services:device") + implementation project(":matrix:services:crypto") + implementation project(":matrix:services:profile") + + implementation project(":core") + + implementation Dependencies.google.androidxComposeUi + implementation Dependencies.mavenCentral.ktorAndroid + implementation Dependencies.mavenCentral.sqldelightAndroid + implementation Dependencies.mavenCentral.matrixOlm + + implementation Dependencies.mavenCentral.kotlinSerializationJson + debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1' +} diff --git a/app/proguard/app.pro b/app/proguard/app.pro new file mode 100644 index 0000000..52ce106 --- /dev/null +++ b/app/proguard/app.pro @@ -0,0 +1,8 @@ +-assumenosideeffects class android.util.Log { + v(...); + d(...); + i(...); + w(...); + e(...); + println(...); +} \ No newline at end of file diff --git a/app/proguard/clear.pro b/app/proguard/clear.pro new file mode 100644 index 0000000..9019a15 --- /dev/null +++ b/app/proguard/clear.pro @@ -0,0 +1 @@ +-keepnames class ** { *; } \ No newline at end of file diff --git a/app/proguard/olm.pro b/app/proguard/olm.pro new file mode 100644 index 0000000..a22f682 --- /dev/null +++ b/app/proguard/olm.pro @@ -0,0 +1 @@ +-keepclassmembers class org.matrix.olm.** { *; } \ No newline at end of file diff --git a/app/proguard/serializationx.pro b/app/proguard/serializationx.pro new file mode 100644 index 0000000..946ad85 --- /dev/null +++ b/app/proguard/serializationx.pro @@ -0,0 +1,16 @@ +-if @kotlinx.serialization.Serializable class ** +-keepclassmembers class <1> { + static <1>$Companion Companion; +} + +-if @kotlinx.serialization.Serializable class ** { + static **$* *; +} +-keepclassmembers class <1>$<3> { + kotlinx.serialization.KSerializer serializer(...); +} + +-keepclassmembers class <1> { + public static <1> INSTANCE; + kotlinx.serialization.KSerializer serializer(...); +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..b2bbec0 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/debug/res/values/com_crashlytics_build_id.xml b/app/src/debug/res/values/com_crashlytics_build_id.xml new file mode 100644 index 0000000..a7e4331 --- /dev/null +++ b/app/src/debug/res/values/com_crashlytics_build_id.xml @@ -0,0 +1,4 @@ + + +00000000000000000000000000000000 + diff --git a/app/src/debug/res/values/values.xml b/app/src/debug/res/values/values.xml new file mode 100644 index 0000000..75b6402 --- /dev/null +++ b/app/src/debug/res/values/values.xml @@ -0,0 +1,10 @@ + + + 390541134533-h9utldf4jb22qd5b6cs2cl8dkoohobjo.apps.googleusercontent.com + 390541134533 + AIzaSyDS_TVmK-thJeXtLpobR1wwbhqJ1hISVik + 1:390541134533:android:3f75d35c4dba1a287b3eac + AIzaSyDS_TVmK-thJeXtLpobR1wwbhqJ1hISVik + helium-6f01a.appspot.com + helium-6f01a + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c0f2b22 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/kotlin/app/dapk/st/SharedPreferencesDelegate.kt b/app/src/main/kotlin/app/dapk/st/SharedPreferencesDelegate.kt new file mode 100644 index 0000000..1ec1edb --- /dev/null +++ b/app/src/main/kotlin/app/dapk/st/SharedPreferencesDelegate.kt @@ -0,0 +1,39 @@ +package app.dapk.st + +import android.content.Context +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.withIoContext +import app.dapk.st.domain.Preferences + +internal class SharedPreferencesDelegate( + context: Context, + fileName: String, + private val coroutineDispatchers: CoroutineDispatchers, +) : Preferences { + + private val preferences by lazy { context.getSharedPreferences(fileName, Context.MODE_PRIVATE) } + + override suspend fun store(key: String, value: String) { + coroutineDispatchers.withIoContext { + preferences.edit().putString(key, value).apply() + } + } + + override suspend fun readString(key: String): String? { + return coroutineDispatchers.withIoContext { + preferences.getString(key, null) + } + } + + override suspend fun remove(key: String) { + coroutineDispatchers.withIoContext { + preferences.edit().remove(key).apply() + } + } + + override suspend fun clear() { + coroutineDispatchers.withIoContext { + preferences.edit().clear().apply() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt b/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt new file mode 100644 index 0000000..a2de52e --- /dev/null +++ b/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt @@ -0,0 +1,76 @@ +package app.dapk.st + +import android.app.Application +import android.util.Log +import app.dapk.st.core.CoreAndroidModule +import app.dapk.st.core.ModuleProvider +import app.dapk.st.core.ProvidableModule +import app.dapk.st.core.attachAppLogger +import app.dapk.st.core.extensions.Scope +import app.dapk.st.core.extensions.unsafeLazy +import app.dapk.st.directory.DirectoryModule +import app.dapk.st.messenger.MessengerModule +import app.dapk.st.graph.AppModule +import app.dapk.st.graph.FeatureModules +import app.dapk.st.home.HomeModule +import app.dapk.st.login.LoginModule +import app.dapk.st.notifications.NotificationsModule +import app.dapk.st.profile.ProfileModule +import app.dapk.st.settings.SettingsModule +import app.dapk.st.work.TaskRunnerModule +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlin.reflect.KClass + +class SmallTalkApplication : Application(), ModuleProvider { + + private val appLogger: (String, String) -> Unit = { tag, message -> _appLogger?.invoke(tag, message) } + private var _appLogger: ((String, String) -> Unit)? = null + + private val appModule: AppModule by unsafeLazy { AppModule(this, appLogger) } + private val featureModules: FeatureModules by unsafeLazy { appModule.featureModules } + private val applicationScope = Scope(Dispatchers.IO) + + override fun onCreate() { + super.onCreate() + val notificationsModule = featureModules.notificationsModule + val storeModule = appModule.storeModule.value + val eventLogStore = storeModule.eventLogStore() + + val logger: (String, String) -> Unit = { tag, message -> + Log.e(tag, message) + GlobalScope.launch { + eventLogStore.insert(tag, message) + } + } + attachAppLogger(logger) + _appLogger = logger + + applicationScope.launch { + notificationsModule.firebasePushTokenUseCase().registerCurrentToken() + storeModule.localEchoStore.preload() + } + + applicationScope.launch { + val notificationsUseCase = notificationsModule.notificationsUseCase() + notificationsUseCase.listenForNotificationChanges() + } + } + + @Suppress("UNCHECKED_CAST", "IMPLICIT_CAST_TO_ANY") + override fun provide(klass: KClass): T { + return when (klass) { + DirectoryModule::class -> featureModules.directoryModule + LoginModule::class -> featureModules.loginModule + HomeModule::class -> featureModules.homeModule + SettingsModule::class -> featureModules.settingsModule + ProfileModule::class -> featureModules.profileModule + NotificationsModule::class -> featureModules.notificationsModule + MessengerModule::class -> featureModules.messengerModule + TaskRunnerModule::class -> appModule.domainModules.taskRunnerModule + CoreAndroidModule::class -> appModule.coreAndroidModule + else -> throw IllegalArgumentException("Unknown: $klass") + } as T + } +} diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt new file mode 100644 index 0000000..48fff84 --- /dev/null +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -0,0 +1,405 @@ +package app.dapk.st.graph + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.Intent +import app.dapk.db.DapkDb +import app.dapk.st.BuildConfig +import app.dapk.st.SharedPreferencesDelegate +import app.dapk.st.core.BuildMeta +import app.dapk.st.core.CoreAndroidModule +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.SingletonFlows +import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.core.extensions.unsafeLazy +import app.dapk.st.directory.DirectoryModule +import app.dapk.st.domain.StoreModule +import app.dapk.st.home.HomeModule +import app.dapk.st.home.MainActivity +import app.dapk.st.imageloader.ImageLoaderModule +import app.dapk.st.login.LoginModule +import app.dapk.st.matrix.MatrixClient +import app.dapk.st.matrix.MatrixTaskRunner +import app.dapk.st.matrix.MatrixTaskRunner.MatrixTask +import app.dapk.st.matrix.auth.authService +import app.dapk.st.matrix.auth.installAuthService +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.crypto.RoomMembersProvider +import app.dapk.st.matrix.crypto.Verification +import app.dapk.st.matrix.crypto.cryptoService +import app.dapk.st.matrix.crypto.installCryptoService +import app.dapk.st.matrix.device.deviceService +import app.dapk.st.matrix.device.installEncryptionService +import app.dapk.st.matrix.device.internal.ApiMessage +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory +import app.dapk.st.matrix.message.* +import app.dapk.st.matrix.push.installPushService +import app.dapk.st.matrix.push.pushService +import app.dapk.st.matrix.room.* +import app.dapk.st.matrix.sync.* +import app.dapk.st.matrix.sync.internal.request.ApiToDeviceEvent +import app.dapk.st.matrix.sync.internal.room.MessageDecrypter +import app.dapk.st.messenger.MessengerActivity +import app.dapk.st.messenger.MessengerModule +import app.dapk.st.navigator.IntentFactory +import app.dapk.st.notifications.NotificationsModule +import app.dapk.st.olm.DeviceKeyFactory +import app.dapk.st.olm.OlmPersistenceWrapper +import app.dapk.st.olm.OlmWrapper +import app.dapk.st.profile.ProfileModule +import app.dapk.st.push.PushModule +import app.dapk.st.settings.SettingsModule +import app.dapk.st.tracking.TrackingModule +import app.dapk.st.work.TaskRunner +import app.dapk.st.work.TaskRunnerModule +import app.dapk.st.work.WorkModule +import app.dapk.st.work.WorkScheduler +import com.squareup.sqldelight.android.AndroidSqliteDriver +import kotlinx.coroutines.Dispatchers +import java.time.Clock + +internal class AppModule(context: Application, logger: MatrixLogger) { + + private val buildMeta = BuildMeta(BuildConfig.VERSION_NAME) + private val trackingModule by unsafeLazy { + TrackingModule( + isCrashTrackingEnabled = !BuildConfig.DEBUG + ) + } + + private val driver = AndroidSqliteDriver(DapkDb.Schema, context, "dapk.db") + private val database = DapkDb(driver) + + private val coroutineDispatchers = CoroutineDispatchers(Dispatchers.IO) + + val storeModule = unsafeLazy { + StoreModule( + database = database, + preferences = SharedPreferencesDelegate(context.applicationContext, fileName = "dapk-user-preferences", coroutineDispatchers), + errorTracker = trackingModule.errorTracker, + credentialPreferences = SharedPreferencesDelegate(context.applicationContext, fileName = "dapk-credentials-preferences", coroutineDispatchers), + databaseDropper = { includeCryptoAccount -> + val cursor = driver.executeQuery( + identifier = null, + sql = "SELECT name FROM sqlite_master WHERE type = 'table' ${if (includeCryptoAccount) "" else "AND name != 'dbCryptoAccount'"}", + parameters = 0 + ) + while (cursor.next()) { + cursor.getString(0)?.let { + driver.execute(null, "DELETE FROM $it", 0) + } + } + }, + coroutineDispatchers = coroutineDispatchers + ) + } + private val workModule = WorkModule(context) + private val imageLoaderModule = ImageLoaderModule(context) + + private val matrixModules = MatrixModules(storeModule, trackingModule, workModule, logger, coroutineDispatchers) + val domainModules = DomainModules(matrixModules, trackingModule.errorTracker) + + val coreAndroidModule = CoreAndroidModule(intentFactory = object : IntentFactory { + override fun home(activity: Activity) = Intent(activity, MainActivity::class.java) + override fun messenger(activity: Activity, roomId: RoomId) = MessengerActivity.newInstance(activity, roomId) + override fun messengerShortcut(activity: Activity, roomId: RoomId) = MessengerActivity.newShortcutInstance(activity, roomId) + }) + + val featureModules = FeatureModules( + storeModule, + matrixModules, + domainModules, + trackingModule, + imageLoaderModule, + context, + buildMeta, + ) +} + +internal class FeatureModules internal constructor( + private val storeModule: Lazy, + private val matrixModules: MatrixModules, + private val domainModules: DomainModules, + private val trackingModule: TrackingModule, + imageLoaderModule: ImageLoaderModule, + context: Context, + buildMeta: BuildMeta, +) { + + val directoryModule by unsafeLazy { + DirectoryModule( + syncService = matrixModules.sync, + messageService = matrixModules.message, + context = context, + credentialsStore = storeModule.value.credentialsStore(), + roomStore = storeModule.value.roomStore(), + roomService = matrixModules.room, + ) + } + val loginModule by unsafeLazy { + LoginModule( + matrixModules.auth, + domainModules.pushModule, + matrixModules.profile, + trackingModule.errorTracker + ) + } + val messengerModule by unsafeLazy { + MessengerModule( + matrixModules.sync, + matrixModules.message, + matrixModules.room, + storeModule.value.credentialsStore(), + storeModule.value.roomStore(), + ) + } + val homeModule by unsafeLazy { HomeModule(storeModule.value, matrixModules.profile) } + val settingsModule by unsafeLazy { SettingsModule(storeModule.value, matrixModules.crypto, matrixModules.sync, context.contentResolver, buildMeta) } + val profileModule by unsafeLazy { ProfileModule(matrixModules.profile) } + val notificationsModule by unsafeLazy { + NotificationsModule( + matrixModules.push, + matrixModules.sync, + storeModule.value.credentialsStore(), + domainModules.pushModule.registerFirebasePushTokenUseCase(), + imageLoaderModule.iconLoader(), + storeModule.value.roomStore(), + context, + ) + } + +} + +internal class MatrixModules( + private val storeModule: Lazy, + private val trackingModule: TrackingModule, + private val workModule: WorkModule, + private val logger: MatrixLogger, + private val coroutineDispatchers: CoroutineDispatchers, +) { + + val matrix by unsafeLazy { + val store = storeModule.value + val credentialsStore = store.credentialsStore() + MatrixClient( + KtorMatrixHttpClientFactory( + credentialsStore, + includeLogging = true + ), + logger + ).also { + it.install { + installAuthService(credentialsStore) + installEncryptionService(store.knownDevicesStore()) + + val olmAccountStore = OlmPersistenceWrapper(store.olmStore()) + val singletonFlows = SingletonFlows() + val olm = OlmWrapper( + olmStore = olmAccountStore, + singletonFlows = singletonFlows, + jsonCanonicalizer = JsonCanonicalizer(), + deviceKeyFactory = DeviceKeyFactory(JsonCanonicalizer()), + errorTracker = trackingModule.errorTracker, + logger = logger, + clock = Clock.systemUTC(), + coroutineDispatchers = coroutineDispatchers, + ) + installCryptoService( + credentialsStore, + olm, + roomMembersProvider = { services -> + RoomMembersProvider { + services.roomService().joinedMembers(it).map { it.userId } + } + }, + coroutineDispatchers = coroutineDispatchers, + ) + installMessageService(store.localEchoStore, BackgroundWorkAdapter(workModule.workScheduler())) { serviceProvider -> + MessageEncrypter { message -> + val result = serviceProvider.cryptoService().encrypt( + roomId = when (message) { + is MessageService.Message.TextMessage -> message.roomId + }, + credentials = credentialsStore.credentials()!!, + when (message) { + is MessageService.Message.TextMessage -> JsonString( + MatrixHttpClient.jsonWithDefaults.encodeToString( + ApiMessage.TextMessage.serializer(), + ApiMessage.TextMessage( + ApiMessage.TextMessage.TextContent( + message.content.body, + message.content.type, + ), message.roomId, type = EventType.ROOM_MESSAGE.value + ) + ) + ) + } + ) + + MessageEncrypter.EncryptedMessagePayload( + result.algorithmName, + result.senderKey, + result.cipherText, + result.sessionId, + result.deviceId, + ) + } + } + + installRoomService( + storeModule.value.memberStore(), + roomMessenger = { + val messageService = it.messageService() + object : RoomMessenger { + override suspend fun enableEncryption(roomId: RoomId) { + messageService.sendEventMessage( + roomId, MessageService.EventMessage.Encryption( + algorithm = AlgorithmName("m.megolm.v1.aes-sha2") + ) + ) + } + } + } + ) + + installProfileService(storeModule.value.profileStore(), singletonFlows, credentialsStore) + + installSyncService( + credentialsStore, + store.overviewStore(), + store.roomStore(), + store.syncStore(), + store.filterStore(), + messageDecrypter = { serviceProvider -> + val cryptoService = serviceProvider.cryptoService() + MessageDecrypter { + cryptoService.decrypt(it) + } + }, + keySharer = { serviceProvider -> + val cryptoService = serviceProvider.cryptoService() + KeySharer { sharedRoomKeys -> + cryptoService.importRoomKeys(sharedRoomKeys) + } + }, + verificationHandler = { services -> + logger.matrixLog(MatrixLogTag.VERIFICATION, "got a verification request $it") + val cryptoService = services.cryptoService() + VerificationHandler { apiEvent -> + cryptoService.onVerificationEvent( + when (apiEvent) { + is ApiToDeviceEvent.VerificationRequest -> Verification.Event.Requested( + apiEvent.sender, + apiEvent.content.fromDevice, + apiEvent.content.transactionId, + apiEvent.content.methods, + apiEvent.content.timestampPosix, + ) + is ApiToDeviceEvent.VerificationReady -> Verification.Event.Ready( + apiEvent.sender, + apiEvent.content.fromDevice, + apiEvent.content.transactionId, + apiEvent.content.methods, + ) + is ApiToDeviceEvent.VerificationStart -> Verification.Event.Started( + apiEvent.sender, + apiEvent.content.fromDevice, + apiEvent.content.method, + apiEvent.content.protocols, + apiEvent.content.hashes, + apiEvent.content.codes, + apiEvent.content.short, + apiEvent.content.transactionId, + ) + is ApiToDeviceEvent.VerificationCancel -> TODO() + is ApiToDeviceEvent.VerificationAccept -> TODO() + is ApiToDeviceEvent.VerificationKey -> Verification.Event.Key( + apiEvent.sender, + apiEvent.content.transactionId, + apiEvent.content.key + ) + is ApiToDeviceEvent.VerificationMac -> Verification.Event.Mac( + apiEvent.sender, + apiEvent.content.transactionId, + apiEvent.content.keys, + apiEvent.content.mac, + ) + } + ) + } + }, + deviceNotifier = { services -> + val encryption = services.deviceService() + val crypto = services.cryptoService() + DeviceNotifier { userIds, syncToken -> + encryption.updateStaleDevices(userIds) + crypto.updateOlmSession(userIds, syncToken) + } + }, + oneTimeKeyProducer = { services -> + val cryptoService = services.cryptoService() + MaybeCreateMoreKeys { + cryptoService.maybeCreateMoreKeys(it) + } + }, + roomMembersService = { services -> + val roomService = services.roomService() + object : RoomMembersService { + override suspend fun find(roomId: RoomId, userIds: List) = roomService.findMembers(roomId, userIds) + override suspend fun insert(roomId: RoomId, members: List) = roomService.insertMembers(roomId, members) + } + }, + errorTracker = trackingModule.errorTracker, + coroutineDispatchers = coroutineDispatchers, + ) + + installPushService(credentialsStore) + + + } + } + } + + val auth by unsafeLazy { matrix.authService() } + val push by unsafeLazy { matrix.pushService() } + val sync by unsafeLazy { matrix.syncService() } + val message by unsafeLazy { matrix.messageService() } + val room by unsafeLazy { matrix.roomService() } + val profile by unsafeLazy { matrix.profileService() } + val crypto by unsafeLazy { matrix.cryptoService() } +} + +internal class DomainModules( + private val matrixModules: MatrixModules, + private val errorTracker: ErrorTracker, +) { + + val pushModule by unsafeLazy { PushModule(matrixModules.push, errorTracker) } + val taskRunnerModule by unsafeLazy { TaskRunnerModule(TaskRunnerAdapter(matrixModules.matrix::run)) } +} + +class BackgroundWorkAdapter(private val workScheduler: WorkScheduler) : BackgroundScheduler { + override fun schedule(key: String, task: BackgroundScheduler.Task) { + workScheduler.schedule( + WorkScheduler.WorkTask( + jobId = 1, + type = task.type, + jsonPayload = task.jsonPayload, + ) + ) + } +} + +class TaskRunnerAdapter(private val matrixTaskRunner: suspend (MatrixTask) -> MatrixTaskRunner.TaskResult) : TaskRunner { + + override suspend fun run(tasks: List): List { + return tasks.map { + when (val result = matrixTaskRunner(MatrixTask(it.task.type, it.task.jsonPayload))) { + is MatrixTaskRunner.TaskResult.Failure -> TaskRunner.TaskResult.Failure(it.source, canRetry = result.canRetry) + MatrixTaskRunner.TaskResult.Success -> TaskRunner.TaskResult.Success(it.source) + } + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..c60bd23 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..75cb802 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,4 @@ + + + + \ 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 new file mode 100644 index 0000000..75cb802 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/app/src/main/res/raw/keep.xml b/app/src/main/res/raw/keep.xml new file mode 100644 index 0000000..8894cff --- /dev/null +++ b/app/src/main/res/raw/keep.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..09837df --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..ce9130c --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + SmallTalk + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..fceae33 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/release/res/values/values.xml b/app/src/release/res/values/values.xml new file mode 100644 index 0000000..75b6402 --- /dev/null +++ b/app/src/release/res/values/values.xml @@ -0,0 +1,10 @@ + + + 390541134533-h9utldf4jb22qd5b6cs2cl8dkoohobjo.apps.googleusercontent.com + 390541134533 + AIzaSyDS_TVmK-thJeXtLpobR1wwbhqJ1hISVik + 1:390541134533:android:3f75d35c4dba1a287b3eac + AIzaSyDS_TVmK-thJeXtLpobR1wwbhqJ1hISVik + helium-6f01a.appspot.com + helium-6f01a + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..ba25d26 --- /dev/null +++ b/build.gradle @@ -0,0 +1,141 @@ +buildscript { + apply from: "dependencies.gradle" + + repositories { + Dependencies._repositories.call(it) + } + dependencies { + classpath Dependencies.google.androidGradlePlugin + classpath Dependencies.mavenCentral.kotlinGradlePlugin + classpath Dependencies.mavenCentral.sqldelightGradlePlugin + classpath Dependencies.mavenCentral.kotlinSerializationGradlePlugin + classpath 'com.google.firebase:firebase-crashlytics-gradle:2.8.1' + } +} + +def launchTask = getGradle() + .getStartParameter() + .getTaskRequests() + .toString() + .toLowerCase() + +subprojects { + tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions { + jvmTarget = "1.8" + freeCompilerArgs = [ + '-Xopt-in=kotlin.contracts.ExperimentalContracts', + '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', + ] + } + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} + +ext.applyMatrixServiceModule = { project -> + project.apply plugin: 'kotlin' + project.apply plugin: 'org.jetbrains.kotlin.plugin.serialization' + + def dependencies = project.dependencies + + dependencies.api project.project(":matrix:matrix") + dependencies.api project.project(":matrix:common") + dependencies.implementation project.project(":matrix:matrix-http") + dependencies.implementation Dependencies.mavenCentral.kotlinSerializationJson +} + +ext.applyLibraryPlugins = { project -> + project.apply plugin: 'com.android.library' + project.apply plugin: 'kotlin-android' +} + +ext.applyCommonAndroidParameters = { project -> + def android = project.android + android.compileSdk 31 + android.compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + incremental = true + } + android.defaultConfig { + minSdkVersion 29 + targetSdkVersion 31 + } + + android.buildFeatures.compose = true + android.composeOptions { + kotlinCompilerExtensionVersion = Dependencies.google.kotlinCompilerExtensionVersion + } +} + +ext.applyLibraryModuleOptimisations = { project -> + project.android { + variantFilter { variant -> + if (variant.name == "debug") { + variant.ignore = true + } + } + + buildFeatures { + buildConfig = false + dataBinding = false + aidl = false + renderScript = false + resValues = false + shaders = false + viewBinding = false + } + } +} + +ext.applyCompose = { project -> + def dependencies = project.dependencies + + dependencies.implementation Dependencies.google.androidxComposeUi + dependencies.implementation Dependencies.google.androidxComposeFoundation + dependencies.implementation Dependencies.google.androidxComposeMaterial + dependencies.implementation Dependencies.google.androidxComposeIconsExtended + dependencies.implementation Dependencies.google.androidxActivityCompose +} + +ext.applyAndroidLibraryModule = { project -> + applyLibraryPlugins(project) + applyCommonAndroidParameters(project) + applyLibraryModuleOptimisations(project) + applyCompose(project) +} + +ext.applyCrashlyticsIfRelease = { project -> + def isReleaseBuild = launchTask.contains("release") + if (isReleaseBuild) { + project.apply plugin: 'com.google.firebase.crashlytics' + project.afterEvaluate { + project.tasks.withType(com.google.firebase.crashlytics.buildtools.gradle.tasks.UploadMappingFileTask).configureEach { + it.googleServicesResourceRoot.set(project.file("src/release/res/")) + } + } + } +} + +ext.kotlinTest = { dependencies -> + dependencies.testImplementation Dependencies.mavenCentral.kluent + dependencies.testImplementation Dependencies.mavenCentral.kotlinTest + dependencies.testImplementation "org.jetbrains.kotlin:kotlin-test-junit:1.6.10" + dependencies.testImplementation 'io.mockk:mockk:1.12.2' + dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0' + + dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2' + dependencies.testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2' +} + +ext.kotlinFixtures = { dependencies -> + dependencies.testFixturesImplementation 'io.mockk:mockk:1.12.2' + dependencies.testFixturesImplementation Dependencies.mavenCentral.kluent +} + +if (launchTask.contains("codeCoverageReport".toLowerCase())) { + apply from: 'tools/coverage.gradle' +} \ No newline at end of file diff --git a/core/build.gradle b/core/build.gradle new file mode 100644 index 0000000..bfbbf22 --- /dev/null +++ b/core/build.gradle @@ -0,0 +1,11 @@ +plugins { + id 'kotlin' + id 'java-test-fixtures' +} + +dependencies { + implementation Dependencies.mavenCentral.kotlinCoroutinesCore + testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore + testFixturesImplementation Dependencies.mavenCentral.kluent + testFixturesImplementation 'io.mockk:mockk:1.12.2' +} \ No newline at end of file diff --git a/core/src/main/kotlin/app/dapk/st/core/BuildMeta.kt b/core/src/main/kotlin/app/dapk/st/core/BuildMeta.kt new file mode 100644 index 0000000..4e965e9 --- /dev/null +++ b/core/src/main/kotlin/app/dapk/st/core/BuildMeta.kt @@ -0,0 +1,5 @@ +package app.dapk.st.core + +data class BuildMeta( + val versionName: String, +) \ No newline at end of file diff --git a/core/src/main/kotlin/app/dapk/st/core/CoroutineDispatchers.kt b/core/src/main/kotlin/app/dapk/st/core/CoroutineDispatchers.kt new file mode 100644 index 0000000..14e8ca2 --- /dev/null +++ b/core/src/main/kotlin/app/dapk/st/core/CoroutineDispatchers.kt @@ -0,0 +1,15 @@ +package app.dapk.st.core + +import kotlinx.coroutines.* + +data class CoroutineDispatchers(val io: CoroutineDispatcher = Dispatchers.IO, val global: CoroutineScope = GlobalScope) + +suspend fun CoroutineDispatchers.withIoContext( + block: suspend CoroutineScope.() -> T +) = withContext(this.io, block) + +suspend fun CoroutineDispatchers.withIoContextAsync( + block: suspend CoroutineScope.() -> T +): Deferred = withContext(this.io) { + async { block() } +} diff --git a/core/src/main/kotlin/app/dapk/st/core/HeliumLogger.kt b/core/src/main/kotlin/app/dapk/st/core/HeliumLogger.kt new file mode 100644 index 0000000..9327337 --- /dev/null +++ b/core/src/main/kotlin/app/dapk/st/core/HeliumLogger.kt @@ -0,0 +1,28 @@ +package app.dapk.st.core + +enum class AppLogTag(val key: String) { + NOTIFICATION("notification"), + PERFORMANCE("performance"), + PUSH("push"), + ERROR_NON_FATAL("error - non fatal"), +} + +typealias AppLogger = (tag: String, message: String) -> Unit + +private var appLoggerInstance: AppLogger? = null + +fun attachAppLogger(logger: AppLogger) { + appLoggerInstance = logger +} + +fun log(tag: AppLogTag, message: Any) { + appLoggerInstance?.invoke(tag.key, message.toString()) +} + +suspend fun logP(area: String, block: suspend () -> T): T { + val start = System.currentTimeMillis() + return block().also { + val timeTaken = System.currentTimeMillis() - start + log(AppLogTag.PERFORMANCE, "$area: took $timeTaken ms") + } +} diff --git a/core/src/main/kotlin/app/dapk/st/core/Lce.kt b/core/src/main/kotlin/app/dapk/st/core/Lce.kt new file mode 100644 index 0000000..1036ab6 --- /dev/null +++ b/core/src/main/kotlin/app/dapk/st/core/Lce.kt @@ -0,0 +1,8 @@ +package app.dapk.st.core + +sealed interface Lce { + class Loading : Lce + data class Error(val cause: Throwable) : Lce + data class Content(val value: T) : Lce +} + diff --git a/core/src/main/kotlin/app/dapk/st/core/ModuleProvider.kt b/core/src/main/kotlin/app/dapk/st/core/ModuleProvider.kt new file mode 100644 index 0000000..67f75a7 --- /dev/null +++ b/core/src/main/kotlin/app/dapk/st/core/ModuleProvider.kt @@ -0,0 +1,10 @@ +package app.dapk.st.core + +import kotlin.reflect.KClass + +interface ModuleProvider { + + fun provide(klass: KClass): T +} + +interface ProvidableModule \ No newline at end of file diff --git a/core/src/main/kotlin/app/dapk/st/core/SingletonFlows.kt b/core/src/main/kotlin/app/dapk/st/core/SingletonFlows.kt new file mode 100644 index 0000000..6b6481f --- /dev/null +++ b/core/src/main/kotlin/app/dapk/st/core/SingletonFlows.kt @@ -0,0 +1,47 @@ +package app.dapk.st.core + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap + +class SingletonFlows { + private val mutex = Mutex() + private val cache = mutableMapOf>() + private val started = ConcurrentHashMap() + + @Suppress("unchecked_cast") + suspend fun getOrPut(key: String, onStart: suspend () -> T): Flow { + return when (val flow = cache[key]) { + null -> mutex.withLock { + cache.getOrPut(key) { + MutableSharedFlow(replay = 1).also { + withContext(Dispatchers.IO) { + async { + it.emit(onStart()) + } + } + } + } as Flow + } + else -> flow as Flow + } + } + + fun get(key: String): Flow { + return cache[key]!! as Flow + } + + suspend fun update(key: String, value: T) { + (cache[key] as? MutableSharedFlow)?.emit(value) + } + + fun remove(key: String) { + cache.remove(key) + } + +} \ No newline at end of file diff --git a/core/src/main/kotlin/app/dapk/st/core/extensions/ErrorTracker.kt b/core/src/main/kotlin/app/dapk/st/core/extensions/ErrorTracker.kt new file mode 100644 index 0000000..80ec91e --- /dev/null +++ b/core/src/main/kotlin/app/dapk/st/core/extensions/ErrorTracker.kt @@ -0,0 +1,15 @@ +package app.dapk.st.core.extensions + +interface ErrorTracker { + fun track(throwable: Throwable, extra: String = "") +} + +interface CrashScope { + val errorTracker: ErrorTracker + fun Result.trackFailure() = this.onFailure { errorTracker.track(it) } +} + +fun ErrorTracker.nullAndTrack(throwable: Throwable, extra: String = ""): T? { + this.track(throwable, extra) + return null +} \ No newline at end of file diff --git a/core/src/main/kotlin/app/dapk/st/core/extensions/FlowExtensions.kt b/core/src/main/kotlin/app/dapk/st/core/extensions/FlowExtensions.kt new file mode 100644 index 0000000..2ffc7da --- /dev/null +++ b/core/src/main/kotlin/app/dapk/st/core/extensions/FlowExtensions.kt @@ -0,0 +1,24 @@ +package app.dapk.st.core.extensions + +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.takeWhile + +@OptIn(InternalCoroutinesApi::class) +suspend fun Flow.firstOrNull(count: Int, predicate: suspend (T) -> Boolean): T? { + var counter = 0 + + var result: T? = null + this + .takeWhile { + counter++ + !predicate(it) || counter < (count + 1) + } + .filter { predicate(it) } + .collect { + result = it + } + + return result +} \ No newline at end of file diff --git a/core/src/main/kotlin/app/dapk/st/core/extensions/GlobalExtensions.kt b/core/src/main/kotlin/app/dapk/st/core/extensions/GlobalExtensions.kt new file mode 100644 index 0000000..5675590 --- /dev/null +++ b/core/src/main/kotlin/app/dapk/st/core/extensions/GlobalExtensions.kt @@ -0,0 +1,23 @@ +package app.dapk.st.core.extensions + +inline fun T?.ifNull(block: () -> T): T = this ?: block() +inline fun ifOrNull(condition: Boolean, block: () -> T): T? = if (condition) block() else null + +@Suppress("UNCHECKED_CAST") +inline fun Iterable.firstOrNull(predicate: (T) -> Boolean, predicate2: (T) -> Boolean): Pair? { + var firstValue: T1? = null + var secondValue: T2? = null + + for (element in this) { + if (firstValue == null && predicate(element)) { + firstValue = element as T1 + } + if (secondValue == null && predicate2(element)) { + secondValue = element as T2 + } + if (firstValue != null && secondValue != null) return firstValue to secondValue + } + return null +} + +fun unsafeLazy(initializer: () -> T): Lazy = lazy(mode = LazyThreadSafetyMode.NONE, initializer = initializer) diff --git a/core/src/main/kotlin/app/dapk/st/core/extensions/LceExtensions.kt b/core/src/main/kotlin/app/dapk/st/core/extensions/LceExtensions.kt new file mode 100644 index 0000000..7bd2fba --- /dev/null +++ b/core/src/main/kotlin/app/dapk/st/core/extensions/LceExtensions.kt @@ -0,0 +1,11 @@ +package app.dapk.st.core.extensions + +import app.dapk.st.core.Lce + +fun Lce.takeIfContent(): T? { + return when (this) { + is Lce.Content -> this.value + is Lce.Error -> null + is Lce.Loading -> null + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/app/dapk/st/core/extensions/ListExtensions.kt b/core/src/main/kotlin/app/dapk/st/core/extensions/ListExtensions.kt new file mode 100644 index 0000000..d42b07b --- /dev/null +++ b/core/src/main/kotlin/app/dapk/st/core/extensions/ListExtensions.kt @@ -0,0 +1,3 @@ +package app.dapk.st.core.extensions + +inline fun List.ifNotEmpty(transform: (List) -> List) = if (this.isEmpty()) emptyList() else transform(this) diff --git a/core/src/main/kotlin/app/dapk/st/core/extensions/Scope.kt b/core/src/main/kotlin/app/dapk/st/core/extensions/Scope.kt new file mode 100644 index 0000000..85e7e92 --- /dev/null +++ b/core/src/main/kotlin/app/dapk/st/core/extensions/Scope.kt @@ -0,0 +1,30 @@ +package app.dapk.st.core.extensions + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +class Scope( + dispatcher: CoroutineDispatcher +) { + + private val job = SupervisorJob() + private val coroutineScope = CoroutineScope(dispatcher + job) + + fun launch( + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit, + ): Job { + return coroutineScope.launch(context, start, block) + } + + fun cancel() { + job.cancel() + } +} \ No newline at end of file diff --git a/core/src/testFixtures/kotlin/fake/FakeErrorTracker.kt b/core/src/testFixtures/kotlin/fake/FakeErrorTracker.kt new file mode 100644 index 0000000..f6f221d --- /dev/null +++ b/core/src/testFixtures/kotlin/fake/FakeErrorTracker.kt @@ -0,0 +1,6 @@ +package fake + +import app.dapk.st.core.extensions.ErrorTracker +import io.mockk.mockk + +class FakeErrorTracker : ErrorTracker by mockk(relaxed = true) \ No newline at end of file diff --git a/core/src/testFixtures/kotlin/test/MockkExtensions.kt b/core/src/testFixtures/kotlin/test/MockkExtensions.kt new file mode 100644 index 0000000..b30c904 --- /dev/null +++ b/core/src/testFixtures/kotlin/test/MockkExtensions.kt @@ -0,0 +1,15 @@ +package test + +import io.mockk.* + +inline fun T.expect(crossinline block: suspend MockKMatcherScope.(T) -> R) { + coEvery { block(this@expect) } returns mockk(relaxed = true) +} + +fun MockKStubScope.delegateReturn(): Returns = Returns { value -> + answers(ConstantAnswer(value)) +} + +fun interface Returns { + fun returns(value: T) +} \ No newline at end of file diff --git a/core/src/testFixtures/kotlin/test/TestSharedFlow.kt b/core/src/testFixtures/kotlin/test/TestSharedFlow.kt new file mode 100644 index 0000000..3768e4b --- /dev/null +++ b/core/src/testFixtures/kotlin/test/TestSharedFlow.kt @@ -0,0 +1,29 @@ +package test + +import kotlinx.coroutines.flow.MutableSharedFlow +import org.amshove.kluent.shouldBeEqualTo + +class TestSharedFlow( + private val instance: MutableSharedFlow = MutableSharedFlow() +) : MutableSharedFlow by instance { + + private val values = mutableListOf() + + override suspend fun emit(value: T) { + values.add(value) + instance.emit(value) + } + + override fun tryEmit(value: T): Boolean { + values.add(value) + return instance.tryEmit(value) + } + + fun assertNoValues() { + values shouldBeEqualTo emptyList() + } + + fun assertValues(vararg expected: T) { + this.values shouldBeEqualTo expected.toList() + } +} \ No newline at end of file diff --git a/dependencies.gradle b/dependencies.gradle new file mode 100644 index 0000000..5d0e7a9 --- /dev/null +++ b/dependencies.gradle @@ -0,0 +1,147 @@ +ext.Dependencies = new DependenciesContainer() + +ext.Dependencies.with { + _repositories = { repositories -> + repositories.google { + content { + includeGroupByRegex "com\\.android.*" + includeGroupByRegex "com\\.google.*" + includeGroupByRegex "androidx\\..*" + } + } + + repositories.mavenCentral { + content { + includeGroupByRegex "org\\.jetbrains.*" + includeGroupByRegex "com\\.google.*" + includeGroupByRegex "com\\.squareup.*" + includeGroupByRegex "com\\.android.*" + includeGroupByRegex "org\\.apache.*" + includeGroupByRegex "org\\.json.*" + includeGroupByRegex "org\\.codehaus.*" + includeGroupByRegex "org\\.jdom.*" + includeGroupByRegex "com\\.fasterxml.*" + includeGroupByRegex "com\\.sun.*" + includeGroupByRegex "org\\.ow2.*" + includeGroupByRegex "org\\.eclipse.*" + includeGroup "app.cash.turbine" + includeGroup "de.undercouch" + includeGroup "de.danielbechler" + includeGroup "com.github.gundy" + includeGroup "com.sun.activation" + includeGroup "com.thoughtworks.qdox" + includeGroup "com.annimon" + includeGroup "com.github.javaparser" + includeGroup "com.beust" + includeGroup "org.bouncycastle" + includeGroup "org.bitbucket.b_c" + includeGroup "org.checkerframework" + includeGroup "org.amshove.kluent" + includeGroup "org.jvnet.staxex" + includeGroup "org.glassfish" + includeGroup "org.glassfish.jaxb" + includeGroup "org.antlr" + includeGroup "org.tensorflow" + includeGroup "org.xerial" + includeGroup "org.slf4j" + includeGroup "org.freemarker" + includeGroup "org.threeten" + includeGroup "org.hamcrest" + includeGroup "org.matrix.android" + includeGroup "org.sonatype.oss" + includeGroup "org.junit.jupiter" + includeGroup "org.junit.platform" + includeGroup "org.junit" + includeGroup "org.junit.jupiter" + includeGroup "org.jsoup" + includeGroup "org.jacoco" + includeGroup "org.testng" + includeGroup "org.opentest4j" + includeGroup "org.apiguardian" + includeGroup "org.webjars" + includeGroup "org.objenesis" + includeGroup "commons-io" + includeGroup "commons-logging" + includeGroup "commons-codec" + includeGroup "net.java.dev.jna" + includeGroup "net.sf.jopt-simple" + includeGroup "net.sf.kxml" + includeGroup "net.bytebuddy" + includeGroup "net.java" + includeGroup "it.unimi.dsi" + includeGroup "io.grpc" + includeGroup "io.netty" + includeGroup "io.opencensus" + includeGroup "io.ktor" + includeGroup "io.coil-kt" + includeGroup "io.mockk" + includeGroup "info.picocli" + includeGroup "us.fatehi" + includeGroup "jakarta.xml.bind" + includeGroup "jakarta.activation" + includeGroup "javax.inject" + includeGroup "junit" + includeGroup "jline" + includeGroup "xerces" + includeGroup "xml-apis" + } + } + } + + def kotlinVer = "1.6.10" + def sqldelightVer = "1.5.3" + def composeVer = "1.1.0" + + google = new DependenciesContainer() + google.with { + androidGradlePlugin = "com.android.tools.build:gradle:7.1.1" + + androidxComposeUi = "androidx.compose.ui:ui:${composeVer}" + androidxComposeFoundation = "androidx.compose.foundation:foundation:${composeVer}" + androidxComposeMaterial = "androidx.compose.material:material:${composeVer}" + androidxComposeIconsExtended = "androidx.compose.material:material-icons-extended:${composeVer}" + androidxActivityCompose = "androidx.activity:activity-compose:1.4.0" + kotlinCompilerExtensionVersion = "1.1.0-rc02" + } + + mavenCentral = new DependenciesContainer() + mavenCentral.with { + kotlinGradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVer}" + kotlinSerializationGradlePlugin = "org.jetbrains.kotlin:kotlin-serialization:${kotlinVer}" + kotlinSerializationJson = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.0" + kotlinCoroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0-RC2" + kotlinTest = "org.jetbrains.kotlin:kotlin-test-junit:${kotlinVer}" + + sqldelightGradlePlugin = "com.squareup.sqldelight:gradle-plugin:${sqldelightVer}" + sqldelightAndroid = "com.squareup.sqldelight:android-driver:${sqldelightVer}" + sqldelightInMemory = "com.squareup.sqldelight:sqlite-driver:${sqldelightVer}" + + ktorAndroid = "io.ktor:ktor-client-android:1.6.4" + ktorCore = "io.ktor:ktor-client-core:1.6.2" + ktorSerialization = "io.ktor:ktor-client-serialization:1.5.0" + ktorLogging = "io.ktor:ktor-client-logging-jvm:1.6.2" + ktorJava = "io.ktor:ktor-client-java:1.6.2" + + junit = "junit:junit:4.13.2" + kluent = "org.amshove.kluent:kluent:1.68" + + matrixOlm = "org.matrix.android:olm-sdk:3.2.10" + } +} + +class DependenciesContainer extends GroovyObjectSupport { + + private final Map storage = new HashMap(); + + @Override + Object getProperty(String name) { + return storage.get(name); + } + + @Override + void setProperty(String name, Object newValue) { + storage.put(name, newValue); + } +} + + diff --git a/design-library/build.gradle b/design-library/build.gradle new file mode 100644 index 0000000..f15ea08 --- /dev/null +++ b/design-library/build.gradle @@ -0,0 +1,7 @@ +applyAndroidLibraryModule(project) + +dependencies { + implementation project(":core") + implementation("io.coil-kt:coil-compose:1.4.0") + implementation "com.google.accompanist:accompanist-systemuicontroller:0.24.1-alpha" +} \ No newline at end of file diff --git a/design-library/src/main/AndroidManifest.xml b/design-library/src/main/AndroidManifest.xml new file mode 100644 index 0000000..03faabd --- /dev/null +++ b/design-library/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/ComposeExtensions.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/ComposeExtensions.kt new file mode 100644 index 0000000..1f10532 --- /dev/null +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/ComposeExtensions.kt @@ -0,0 +1,9 @@ +package app.dapk.st.design.components + +import android.content.res.Configuration +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +fun Configuration.percentOfHeight(float: Float): Dp { + return (this.screenHeightDp * float).dp +} \ No newline at end of file diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Icon.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Icon.kt new file mode 100644 index 0000000..8db9a78 --- /dev/null +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Icon.kt @@ -0,0 +1,79 @@ +package app.dapk.st.design.components + +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.size +import androidx.compose.foundation.shape.CircleShape +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 androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.ExperimentalUnitApi +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import coil.compose.rememberImagePainter +import coil.transform.CircleCropTransformation + +@OptIn(ExperimentalUnitApi::class) +@Composable +fun BoxScope.CircleishAvatar(avatarUrl: String?, fallbackLabel: String, size: Dp) { + when (avatarUrl) { + null -> { + val colors = SmallTalkTheme.extendedColors.getMissingImageColor(fallbackLabel) + Box( + Modifier.align(Alignment.Center) + .background(color = colors.first, shape = CircleShape) + .size(size), + contentAlignment = Alignment.Center + ) { + Text( + text = fallbackLabel.uppercase().first().toString(), + color = colors.second, + fontWeight = FontWeight.Medium, + fontSize = TextUnit(size.value * 0.5f, TextUnitType.Sp) + ) + } + } + else -> { + Image( + painter = rememberImagePainter( + data = avatarUrl, + builder = { + transformations(CircleCropTransformation()) + } + ), + contentDescription = null, + modifier = Modifier.size(size).align(Alignment.Center) + ) + } + } +} + +@Composable +fun MissingAvatarIcon(displayName: String, displayImageSize: Dp) { + val colors = SmallTalkTheme.extendedColors.getMissingImageColor(displayName) + Box(Modifier.background(color = colors.first, shape = CircleShape).size(displayImageSize), contentAlignment = Alignment.Center) { + Text( + text = (displayName).first().toString().uppercase(), + color = colors.second + ) + } +} + +@Composable +fun MessengerUrlIcon(avatarUrl: String, displayImageSize: Dp) { + Image( + painter = rememberImagePainter( + data = avatarUrl, + builder = { + transformations(CircleCropTransformation()) + } + ), + contentDescription = null, + modifier = Modifier.size(displayImageSize) + ) +} \ No newline at end of file diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/OverflowMenu.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/OverflowMenu.kt new file mode 100644 index 0000000..72a9186 --- /dev/null +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/OverflowMenu.kt @@ -0,0 +1,34 @@ +package app.dapk.st.design.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.material.DropdownMenu +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.runtime.* +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp + +@Composable +fun OverflowMenu(content: @Composable () -> Unit) { + var showMenu by remember { mutableStateOf(false) } + + Box { + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + offset = DpOffset(0.dp, (-72).dp) + ) { + content() + } + IconButton(onClick = { + showMenu = !showMenu + }) { + Icon( + imageVector = Icons.Outlined.MoreVert, + contentDescription = null, + ) + } + } +} \ No newline at end of file diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Spider.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Spider.kt new file mode 100644 index 0000000..ca3cabe --- /dev/null +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Spider.kt @@ -0,0 +1,55 @@ +package app.dapk.st.design.components + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +@Composable +fun Spider(currentPage: SpiderPage, onNavigate: (SpiderPage?) -> Unit, graph: SpiderScope.() -> Unit) { + val pageCache = remember { mutableMapOf, SpiderPage>() } + pageCache[currentPage.route] = currentPage + + val computedWeb = remember(true) { + mutableMapOf, @Composable (T) -> Unit>().also { computedWeb -> + val scope = object : SpiderScope { + override fun item(route: Route, content: @Composable (T) -> Unit) { + computedWeb[route] = { content(it as T) } + } + } + graph.invoke(scope) + } + } + + val navigateAndPopStack = { + pageCache.remove(currentPage.route) + onNavigate(pageCache[currentPage.parent]) + } + + Column { + Toolbar( + onNavigate = navigateAndPopStack, + title = currentPage.label + ) + + currentPage.parent?.let { + BackHandler(onBack = navigateAndPopStack) + } + computedWeb[currentPage.route]!!.invoke(currentPage.state) + } +} + + +interface SpiderScope { + fun item(route: Route, content: @Composable (T) -> Unit) +} + +data class SpiderPage( + val route: Route, + val label: String, + val parent: Route<*>?, + val state: T, +) + +@JvmInline +value class Route(val value: String) \ No newline at end of file diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Theme.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Theme.kt new file mode 100644 index 0000000..c992f69 --- /dev/null +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Theme.kt @@ -0,0 +1,71 @@ +package app.dapk.st.design.components + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material.MaterialTheme +import androidx.compose.material.darkColors +import androidx.compose.runtime.* +import androidx.compose.ui.graphics.Color +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import kotlin.math.absoluteValue + +private object Palette { + val brandPrimary = Color(0xFFb41cca) +} + +private val DARK_COLOURS = darkColors( + primary = Palette.brandPrimary, + onPrimary = Color(0xDDFFFFFF), +) + +private val LIGHT_COLOURS = DARK_COLOURS + +private val DARK_EXTENDED = ExtendedColors( + selfBubble = DARK_COLOURS.primary, + onSelfBubble = DARK_COLOURS.onPrimary, + othersBubble = Color(0x20EDEDED), + onOthersBubble = Color(0xFF000000), + missingImageColors = listOf( + Color(0xFFf7c7f7) to Color(0xFFdf20de), + Color(0xFFe5d7f6) to Color(0xFF7b30cf), + Color(0xFFf6c8cb) to Color(0xFFda2535), + ) +) +private val LIGHT_EXTENDED = DARK_EXTENDED + +@Immutable +data class ExtendedColors( + val selfBubble: Color, + val onSelfBubble: Color, + val othersBubble: Color, + val onOthersBubble: Color, + val missingImageColors: List>, +) { + fun getMissingImageColor(key: String): Pair { + return missingImageColors[key.hashCode().absoluteValue % (missingImageColors.size)] + } +} + +private val LocalExtendedColors = staticCompositionLocalOf { LIGHT_EXTENDED } + +@Composable +fun SmallTalkTheme(content: @Composable () -> Unit) { + val systemUiController = rememberSystemUiController() + val systemInDarkTheme = isSystemInDarkTheme() + MaterialTheme( + colors = if (systemInDarkTheme) DARK_COLOURS else LIGHT_COLOURS, + ) { + val backgroundColor = MaterialTheme.colors.background + SideEffect { + systemUiController.setSystemBarsColor(backgroundColor) + } + CompositionLocalProvider(LocalExtendedColors provides if (systemInDarkTheme) DARK_EXTENDED else LIGHT_EXTENDED) { + content() + } + } +} + +object SmallTalkTheme { + val extendedColors: ExtendedColors + @Composable + get() = LocalExtendedColors.current +} \ No newline at end of file diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Toolbar.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Toolbar.kt new file mode 100644 index 0000000..60567c7 --- /dev/null +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Toolbar.kt @@ -0,0 +1,32 @@ +package app.dapk.st.design.components + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun ColumnScope.Toolbar(onNavigate: () -> Unit, title: String? = null, actions: @Composable RowScope.() -> Unit = {}) { + TopAppBar( + modifier = Modifier.height(72.dp), + backgroundColor = Color.Transparent, + navigationIcon = { + IconButton(onClick = { onNavigate() }) { + Icon(Icons.Default.ArrowBack, contentDescription = null) + } + }, + title = title?.let { + { Text(it, maxLines = 2) } + } ?: {}, + actions = actions, + elevation = 0.dp + ) + Divider(modifier = Modifier.fillMaxWidth(), color = Color.Black.copy(alpha = 0.2f), thickness = 0.5.dp) +} \ No newline at end of file diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/itemRow.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/itemRow.kt new file mode 100644 index 0000000..d76316e --- /dev/null +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/itemRow.kt @@ -0,0 +1,60 @@ +package app.dapk.st.design.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.Divider +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.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun TextRow(title: String, content: String? = null, includeDivider: Boolean = true, onClick: (() -> Unit)? = null) { + val modifier = Modifier.padding(horizontal = 24.dp) + Column( + Modifier + .fillMaxWidth() + .clickable(enabled = onClick != null) { onClick?.invoke() }) { + Spacer(modifier = Modifier.height(24.dp)) + Column(modifier) { + when (content) { + null -> { + Text(text = title, fontSize = 18.sp) + } + else -> { + Text(text = title, fontSize = 12.sp) + Spacer(modifier = Modifier.height(2.dp)) + Text(text = content, fontSize = 18.sp) + } + } + Spacer(modifier = Modifier.height(24.dp)) + } + if (includeDivider) { + Divider(modifier = Modifier.fillMaxWidth()) + } + } +} + +@Composable +fun IconRow(icon: ImageVector, title: String, onClick: (() -> Unit)? = null) { + Row( + Modifier + .fillMaxWidth() + .clickable(enabled = onClick != null) { onClick?.invoke() } + .padding(24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(icon, contentDescription = null) + Spacer(modifier = Modifier.width(12.dp)) + Text(text = title, fontSize = 18.sp) + } +} + +@Composable +fun SettingsTextRow(title: String, subtitle: String?, onClick: (() -> Unit)?) { + TextRow(title = title, subtitle, includeDivider = false, onClick) +} diff --git a/domains/android/core/build.gradle b/domains/android/core/build.gradle new file mode 100644 index 0000000..896c7c4 --- /dev/null +++ b/domains/android/core/build.gradle @@ -0,0 +1,6 @@ +applyAndroidLibraryModule(project) + +dependencies { + implementation project(":core") + implementation project(":features:navigator") +} diff --git a/domains/android/core/src/main/AndroidManifest.xml b/domains/android/core/src/main/AndroidManifest.xml new file mode 100644 index 0000000..86bfe2b --- /dev/null +++ b/domains/android/core/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/domains/android/core/src/main/kotlin/app/dapk/st/core/ActivityExtensions.kt b/domains/android/core/src/main/kotlin/app/dapk/st/core/ActivityExtensions.kt new file mode 100644 index 0000000..8762808 --- /dev/null +++ b/domains/android/core/src/main/kotlin/app/dapk/st/core/ActivityExtensions.kt @@ -0,0 +1,19 @@ +package app.dapk.st.core + +import androidx.activity.ComponentActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelLazy +import androidx.lifecycle.ViewModelProvider.* + +inline fun ComponentActivity.viewModel( + noinline factory: () -> VM +): Lazy { + val factoryPromise = object : Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class) = when (modelClass) { + VM::class.java -> factory() as T + else -> throw Error() + } + } + return ViewModelLazy(VM::class, { viewModelStore }, { factoryPromise }) +} diff --git a/domains/android/core/src/main/kotlin/app/dapk/st/core/ComposeExtensions.kt b/domains/android/core/src/main/kotlin/app/dapk/st/core/ComposeExtensions.kt new file mode 100644 index 0000000..4166d6b --- /dev/null +++ b/domains/android/core/src/main/kotlin/app/dapk/st/core/ComposeExtensions.kt @@ -0,0 +1,53 @@ +package app.dapk.st.core + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@Composable +fun StartObserving(block: StartScope.() -> Unit) { + LaunchedEffect(true) { + block(StartScope(this)) + } +} + +class StartScope(private val scope: CoroutineScope) { + + fun SharedFlow.launch(onEach: suspend (T) -> Unit) { + this.onEach(onEach).launchIn(scope) + } +} + +interface EffectScope { + + @Composable + fun OnceEffect(key: Any, sideEffect: () -> Unit) +} + + +@Composable +fun LifecycleEffect(onStart: () -> Unit = {}, onStop: () -> Unit = {}) { + val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) + DisposableEffect(lifecycleOwner.value) { + val lifecycleObserver = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> onStart() + Lifecycle.Event.ON_STOP -> onStop() + } + } + + lifecycleOwner.value.lifecycle.addObserver(lifecycleObserver) + + onDispose { + lifecycleOwner.value.lifecycle.removeObserver(lifecycleObserver) + } + } +} \ No newline at end of file diff --git a/domains/android/core/src/main/kotlin/app/dapk/st/core/ContextExtensions.kt b/domains/android/core/src/main/kotlin/app/dapk/st/core/ContextExtensions.kt new file mode 100644 index 0000000..0d426b0 --- /dev/null +++ b/domains/android/core/src/main/kotlin/app/dapk/st/core/ContextExtensions.kt @@ -0,0 +1,6 @@ +package app.dapk.st.core + +import android.content.Context + +inline fun Context.module() = + (this.applicationContext as ModuleProvider).provide(T::class) \ No newline at end of file diff --git a/domains/android/core/src/main/kotlin/app/dapk/st/core/CoreAndroidModule.kt b/domains/android/core/src/main/kotlin/app/dapk/st/core/CoreAndroidModule.kt new file mode 100644 index 0000000..5829e0a --- /dev/null +++ b/domains/android/core/src/main/kotlin/app/dapk/st/core/CoreAndroidModule.kt @@ -0,0 +1,9 @@ +package app.dapk.st.core + +import app.dapk.st.navigator.IntentFactory + +class CoreAndroidModule(private val intentFactory: IntentFactory): ProvidableModule { + + fun intentFactory() = intentFactory + +} \ No newline at end of file diff --git a/domains/android/core/src/main/kotlin/app/dapk/st/core/DapkActivity.kt b/domains/android/core/src/main/kotlin/app/dapk/st/core/DapkActivity.kt new file mode 100644 index 0000000..3904972 --- /dev/null +++ b/domains/android/core/src/main/kotlin/app/dapk/st/core/DapkActivity.kt @@ -0,0 +1,33 @@ +package app.dapk.st.core + +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import app.dapk.st.core.extensions.unsafeLazy +import app.dapk.st.navigator.navigator + +abstract class DapkActivity : ComponentActivity(), EffectScope { + + private val coreAndroidModule by unsafeLazy { module() } + private val remembers = mutableMapOf() + protected val navigator by navigator { coreAndroidModule.intentFactory() } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); + } + + @Composable + override fun OnceEffect(key: Any, sideEffect: () -> Unit) { + val triggerSideEffect = remembers.containsKey(key).not() + if (triggerSideEffect) { + remembers[key] = Unit + SideEffect { + sideEffect() + } + } + } +} diff --git a/domains/android/core/src/main/kotlin/app/dapk/st/core/DapkViewModel.kt b/domains/android/core/src/main/kotlin/app/dapk/st/core/DapkViewModel.kt new file mode 100644 index 0000000..29ac3e4 --- /dev/null +++ b/domains/android/core/src/main/kotlin/app/dapk/st/core/DapkViewModel.kt @@ -0,0 +1,22 @@ +package app.dapk.st.core + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow + +abstract class DapkViewModel( + initialState: S +) : ViewModel() { + + protected val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events: SharedFlow = _events + var state by mutableStateOf(initialState) + protected set + + fun updateState(reducer: S.() -> S) { + state = reducer(state) + } +} \ No newline at end of file diff --git a/domains/android/core/src/main/kotlin/app/dapk/st/core/components/Components.kt b/domains/android/core/src/main/kotlin/app/dapk/st/core/components/Components.kt new file mode 100644 index 0000000..3a793d9 --- /dev/null +++ b/domains/android/core/src/main/kotlin/app/dapk/st/core/components/Components.kt @@ -0,0 +1,29 @@ +package app.dapk.st.core.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun Header(label: String) { + Box(Modifier.padding(top = 24.dp, start = 24.dp, end = 24.dp)) { + Text(text = label.uppercase(), fontWeight = FontWeight.Bold, fontSize = 12.sp, color = MaterialTheme.colors.primary) + } +} + +@Composable +fun CenteredLoading() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.wrapContentSize()) + } +} \ No newline at end of file diff --git a/domains/android/imageloader/build.gradle b/domains/android/imageloader/build.gradle new file mode 100644 index 0000000..3ed97a3 --- /dev/null +++ b/domains/android/imageloader/build.gradle @@ -0,0 +1,6 @@ +applyAndroidLibraryModule(project) + +dependencies { + implementation project(":core") + implementation "io.coil-kt:coil:1.4.0" +} diff --git a/domains/android/imageloader/src/main/AndroidManifest.xml b/domains/android/imageloader/src/main/AndroidManifest.xml new file mode 100644 index 0000000..fa21376 --- /dev/null +++ b/domains/android/imageloader/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/domains/android/imageloader/src/main/kotlin/app/dapk/st/imageloader/ImageLoader.kt b/domains/android/imageloader/src/main/kotlin/app/dapk/st/imageloader/ImageLoader.kt new file mode 100644 index 0000000..f84db5c --- /dev/null +++ b/domains/android/imageloader/src/main/kotlin/app/dapk/st/imageloader/ImageLoader.kt @@ -0,0 +1,62 @@ +package app.dapk.st.imageloader + +import android.content.Context +import android.graphics.drawable.Drawable +import android.graphics.drawable.Icon +import android.widget.ImageView +import androidx.core.graphics.drawable.toBitmap +import coil.imageLoader +import coil.request.ImageRequest +import coil.transform.CircleCropTransformation +import coil.transform.Transformation +import coil.load as coilLoad + +interface ImageLoader { + + suspend fun load(url: String, transformation: Transformation? = null): Drawable? + +} + +interface IconLoader { + + suspend fun load(url: String): Icon? + +} + + +class CachedIcons(private val imageLoader: ImageLoader) : IconLoader { + + private val circleCrop = CircleCropTransformation() + private val cache = mutableMapOf() + + override suspend fun load(url: String): Icon? { + return cache.getOrPut(url) { + imageLoader.load(url, transformation = circleCrop)?.toBitmap()?.let { + Icon.createWithBitmap(it) + } + } + } +} + + +internal class CoilImageLoader(private val context: Context) : ImageLoader { + + private val coil = context.imageLoader + + override suspend fun load(url: String, transformation: Transformation?): Drawable? { + val request = ImageRequest.Builder(context) + .data(url) + .let { + when (transformation) { + null -> it + else -> it.transformations(transformation) + } + } + .build() + return coil.execute(request).drawable + } +} + +fun ImageView.load(url: String) { + this.coilLoad(url) +} \ No newline at end of file diff --git a/domains/android/imageloader/src/main/kotlin/app/dapk/st/imageloader/ImageLoaderModule.kt b/domains/android/imageloader/src/main/kotlin/app/dapk/st/imageloader/ImageLoaderModule.kt new file mode 100644 index 0000000..d292c02 --- /dev/null +++ b/domains/android/imageloader/src/main/kotlin/app/dapk/st/imageloader/ImageLoaderModule.kt @@ -0,0 +1,16 @@ +package app.dapk.st.imageloader + +import android.content.Context +import app.dapk.st.core.extensions.unsafeLazy + +class ImageLoaderModule( + private val context: Context, +) { + + private val imageLoader by unsafeLazy { CoilImageLoader(context) } + + private val cachedIcons by unsafeLazy { CachedIcons(imageLoader) } + + fun iconLoader(): IconLoader = cachedIcons + +} \ No newline at end of file diff --git a/domains/android/push/build.gradle b/domains/android/push/build.gradle new file mode 100644 index 0000000..4f21b7a --- /dev/null +++ b/domains/android/push/build.gradle @@ -0,0 +1,8 @@ +applyAndroidLibraryModule(project) + +dependencies { + implementation project(':core') + implementation project(':matrix:services:push') + implementation platform('com.google.firebase:firebase-bom:29.0.3') + implementation 'com.google.firebase:firebase-messaging' +} diff --git a/domains/android/push/src/main/AndroidManifest.xml b/domains/android/push/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a675a3a --- /dev/null +++ b/domains/android/push/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/FirebaseMessagingExtensions.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/FirebaseMessagingExtensions.kt new file mode 100644 index 0000000..78a8033 --- /dev/null +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/FirebaseMessagingExtensions.kt @@ -0,0 +1,18 @@ +package app.dapk.st.push + +import com.google.firebase.messaging.FirebaseMessaging +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +suspend fun FirebaseMessaging.token() = suspendCoroutine { continuation -> + this.token.addOnCompleteListener { task -> + when { + task.isSuccessful -> continuation.resume(task.result!!) + task.isCanceled -> continuation.resumeWith(Result.failure(CancelledTokenFetchingException())) + else -> continuation.resumeWith(Result.failure(task.exception ?: UnknownTokenFetchingFailedException())) + } + } +} + +private class CancelledTokenFetchingException : Throwable() +private class UnknownTokenFetchingFailedException : Throwable() \ No newline at end of file diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/PushModule.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/PushModule.kt new file mode 100644 index 0000000..5978b95 --- /dev/null +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/PushModule.kt @@ -0,0 +1,16 @@ +package app.dapk.st.push + +import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.matrix.push.PushService + +class PushModule( + private val pushService: PushService, + private val errorTracker: ErrorTracker, +) { + + fun registerFirebasePushTokenUseCase() = RegisterFirebasePushTokenUseCase( + pushService, + errorTracker, + ) + +} \ No newline at end of file diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/RegisterFirebasePushTokenUseCase.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/RegisterFirebasePushTokenUseCase.kt new file mode 100644 index 0000000..4ef213b --- /dev/null +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/RegisterFirebasePushTokenUseCase.kt @@ -0,0 +1,27 @@ +package app.dapk.st.push + +import app.dapk.st.core.AppLogTag +import app.dapk.st.core.extensions.CrashScope +import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.core.log +import app.dapk.st.matrix.push.PushService +import com.google.firebase.messaging.FirebaseMessaging + +class RegisterFirebasePushTokenUseCase( + private val pushService: PushService, + override val errorTracker: ErrorTracker, +) : CrashScope { + + suspend fun registerCurrentToken() { + kotlin.runCatching { + FirebaseMessaging.getInstance().token().also { + pushService.registerPush(it) + } + } + .trackFailure() + .onSuccess { + log(AppLogTag.PUSH, "registered new push token") + } + } + +} \ No newline at end of file diff --git a/domains/android/tracking/build.gradle b/domains/android/tracking/build.gradle new file mode 100644 index 0000000..8545a2f --- /dev/null +++ b/domains/android/tracking/build.gradle @@ -0,0 +1,10 @@ +applyAndroidLibraryModule(project) + +dependencies { + implementation project(':core') + implementation platform('com.google.firebase:firebase-bom:29.0.3') + implementation 'com.google.firebase:firebase-crashlytics' + + // is it worth the 400kb size increase? +// implementation 'com.google.firebase:firebase-analytics' +} diff --git a/domains/android/tracking/src/main/AndroidManifest.xml b/domains/android/tracking/src/main/AndroidManifest.xml new file mode 100644 index 0000000..79ad5d4 --- /dev/null +++ b/domains/android/tracking/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/domains/android/tracking/src/main/kotlin/app/dapk/st/tracking/CrashlyticsCrashTracker.kt b/domains/android/tracking/src/main/kotlin/app/dapk/st/tracking/CrashlyticsCrashTracker.kt new file mode 100644 index 0000000..158884a --- /dev/null +++ b/domains/android/tracking/src/main/kotlin/app/dapk/st/tracking/CrashlyticsCrashTracker.kt @@ -0,0 +1,19 @@ +package app.dapk.st.tracking + +import android.util.Log +import app.dapk.st.core.AppLogTag +import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.core.log +import com.google.firebase.crashlytics.FirebaseCrashlytics + +class CrashlyticsCrashTracker( + private val firebaseCrashlytics: FirebaseCrashlytics, +) : ErrorTracker { + + override fun track(throwable: Throwable, extra: String) { + Log.e("ST", throwable.message, throwable) + log(AppLogTag.ERROR_NON_FATAL, "${throwable.message ?: "N/A"} extra=$extra") + firebaseCrashlytics.recordException(throwable) + } +} + diff --git a/domains/android/tracking/src/main/kotlin/app/dapk/st/tracking/TrackingModule.kt b/domains/android/tracking/src/main/kotlin/app/dapk/st/tracking/TrackingModule.kt new file mode 100644 index 0000000..e097607 --- /dev/null +++ b/domains/android/tracking/src/main/kotlin/app/dapk/st/tracking/TrackingModule.kt @@ -0,0 +1,23 @@ +package app.dapk.st.tracking + +import android.util.Log +import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.core.extensions.unsafeLazy +import com.google.firebase.crashlytics.FirebaseCrashlytics + +class TrackingModule( + private val isCrashTrackingEnabled: Boolean, +) { + + val errorTracker: ErrorTracker by unsafeLazy { + when (isCrashTrackingEnabled) { + true -> CrashlyticsCrashTracker(FirebaseCrashlytics.getInstance()) + false -> object : ErrorTracker { + override fun track(throwable: Throwable, extra: String) { + Log.e("error", throwable.message, throwable) + } + } + } + } + +} \ No newline at end of file diff --git a/domains/android/work/build.gradle b/domains/android/work/build.gradle new file mode 100644 index 0000000..ee78269 --- /dev/null +++ b/domains/android/work/build.gradle @@ -0,0 +1,6 @@ +applyAndroidLibraryModule(project) + +dependencies { + implementation project(':core') + implementation project(':domains:android:core') +} diff --git a/domains/android/work/src/main/AndroidManifest.xml b/domains/android/work/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b680b05 --- /dev/null +++ b/domains/android/work/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + + + \ No newline at end of file diff --git a/domains/android/work/src/main/kotlin/app/dapk/st/work/TaskRunner.kt b/domains/android/work/src/main/kotlin/app/dapk/st/work/TaskRunner.kt new file mode 100644 index 0000000..a1dff08 --- /dev/null +++ b/domains/android/work/src/main/kotlin/app/dapk/st/work/TaskRunner.kt @@ -0,0 +1,22 @@ +package app.dapk.st.work + +import android.app.job.JobWorkItem +import app.dapk.st.work.WorkScheduler.WorkTask + +interface TaskRunner { + + suspend fun run(tasks: List): List + + data class RunnableWorkTask( + val source: JobWorkItem, + val task: WorkTask + ) + + sealed interface TaskResult { + val source: JobWorkItem + + data class Success(override val source: JobWorkItem) : TaskResult + data class Failure(override val source: JobWorkItem, val canRetry: Boolean) : TaskResult + } + +} \ No newline at end of file diff --git a/domains/android/work/src/main/kotlin/app/dapk/st/work/WorkAndroidService.kt b/domains/android/work/src/main/kotlin/app/dapk/st/work/WorkAndroidService.kt new file mode 100644 index 0000000..e712235 --- /dev/null +++ b/domains/android/work/src/main/kotlin/app/dapk/st/work/WorkAndroidService.kt @@ -0,0 +1,72 @@ +package app.dapk.st.work + +import android.app.job.JobParameters +import android.app.job.JobService +import android.app.job.JobWorkItem +import app.dapk.st.core.extensions.Scope +import app.dapk.st.core.extensions.unsafeLazy +import app.dapk.st.core.module +import app.dapk.st.work.TaskRunner.RunnableWorkTask +import app.dapk.st.work.WorkScheduler.WorkTask +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job + +class WorkAndroidService : JobService() { + + private val module by unsafeLazy { module() } + private val serviceScope = Scope(Dispatchers.IO) + private var currentJob: Job? = null + + override fun onStartJob(params: JobParameters): Boolean { + currentJob = serviceScope.launch { + val results = module.taskRunner().run(params.collectAllTasks()) + results.forEach { + when (it) { + is TaskRunner.TaskResult.Failure -> { + if (!it.canRetry) { + params.completeWork(it.source) + } + } + is TaskRunner.TaskResult.Success -> { + params.completeWork(it.source) + } + } + } + + val shouldReschedule = results.any { it is TaskRunner.TaskResult.Failure && it.canRetry } + jobFinished(params, shouldReschedule) + } + return true + } + + private fun JobParameters.collectAllTasks(): List { + var work: JobWorkItem? + val tasks = mutableListOf() + do { + work = this.dequeueWork() + work?.intent?.also { intent -> + tasks.add( + RunnableWorkTask( + source = work, + task = WorkTask( + jobId = this.jobId, + type = intent.getStringExtra("task-type")!!, + jsonPayload = intent.getStringExtra("task-payload")!!, + ) + ) + ) + } + } while (work != null) + return tasks + } + + override fun onStopJob(params: JobParameters): Boolean { + currentJob?.cancel() + return true + } + + override fun onDestroy() { + serviceScope.cancel() + super.onDestroy() + } +} \ No newline at end of file diff --git a/domains/android/work/src/main/kotlin/app/dapk/st/work/WorkModule.kt b/domains/android/work/src/main/kotlin/app/dapk/st/work/WorkModule.kt new file mode 100644 index 0000000..c6f148a --- /dev/null +++ b/domains/android/work/src/main/kotlin/app/dapk/st/work/WorkModule.kt @@ -0,0 +1,12 @@ +package app.dapk.st.work + +import android.content.Context +import app.dapk.st.core.ProvidableModule + +class WorkModule(private val context: Context) { + fun workScheduler(): WorkScheduler = WorkSchedulingJobScheduler(context) +} + +class TaskRunnerModule(private val taskRunner: TaskRunner) : ProvidableModule { + fun taskRunner() = taskRunner +} \ No newline at end of file diff --git a/domains/android/work/src/main/kotlin/app/dapk/st/work/WorkScheduler.kt b/domains/android/work/src/main/kotlin/app/dapk/st/work/WorkScheduler.kt new file mode 100644 index 0000000..19c88a9 --- /dev/null +++ b/domains/android/work/src/main/kotlin/app/dapk/st/work/WorkScheduler.kt @@ -0,0 +1,9 @@ +package app.dapk.st.work + +interface WorkScheduler { + + fun schedule(task: WorkTask) + + data class WorkTask(val jobId: Int, val type: String, val jsonPayload: String) + +} \ No newline at end of file diff --git a/domains/android/work/src/main/kotlin/app/dapk/st/work/WorkSchedulingJobScheduler.kt b/domains/android/work/src/main/kotlin/app/dapk/st/work/WorkSchedulingJobScheduler.kt new file mode 100644 index 0000000..b4a70ef --- /dev/null +++ b/domains/android/work/src/main/kotlin/app/dapk/st/work/WorkSchedulingJobScheduler.kt @@ -0,0 +1,34 @@ +package app.dapk.st.work + +import android.app.job.JobInfo +import android.app.job.JobScheduler +import android.app.job.JobWorkItem +import android.content.ComponentName +import android.content.Context +import android.content.Intent + +internal class WorkSchedulingJobScheduler( + private val context: Context, +) : WorkScheduler { + + private val jobScheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler + + override fun schedule(task: WorkScheduler.WorkTask) { + val job = JobInfo.Builder(100, ComponentName(context, WorkAndroidService::class.java)) + .setMinimumLatency(1) + .setOverrideDeadline(1) + .setBackoffCriteria(1000L, JobInfo.BACKOFF_POLICY_EXPONENTIAL) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .setRequiresCharging(false) + .setRequiresDeviceIdle(false) + .build() + + val item = JobWorkItem( + Intent() + .putExtra("task-type", task.type) + .putExtra("task-payload", task.jsonPayload) + ) + + jobScheduler.enqueue(job, item) + } +} diff --git a/domains/olm-stub/build.gradle b/domains/olm-stub/build.gradle new file mode 100644 index 0000000..5717824 --- /dev/null +++ b/domains/olm-stub/build.gradle @@ -0,0 +1,7 @@ +plugins { + id 'kotlin' +} + +dependencies { + compileOnly 'org.json:json:20211205' +} \ No newline at end of file diff --git a/domains/olm-stub/src/main/java/org/matrix/olm/OlmAccount.java b/domains/olm-stub/src/main/java/org/matrix/olm/OlmAccount.java new file mode 100644 index 0000000..66008bb --- /dev/null +++ b/domains/olm-stub/src/main/java/org/matrix/olm/OlmAccount.java @@ -0,0 +1,85 @@ +package org.matrix.olm; + +import java.io.Serializable; +import java.util.Map; + +public class OlmAccount implements Serializable { + public static final String JSON_KEY_ONE_TIME_KEY = "curve25519"; + public static final String JSON_KEY_IDENTITY_KEY = "curve25519"; + public static final String JSON_KEY_FINGER_PRINT_KEY = "ed25519"; + + public OlmAccount() throws OlmException { + throw new RuntimeException("stub"); + } + + long getOlmAccountId() { + throw new RuntimeException("stub"); + } + + public void releaseAccount() { + throw new RuntimeException("stub"); + } + + public boolean isReleased() { + throw new RuntimeException("stub"); + } + + public Map identityKeys() throws OlmException { + throw new RuntimeException("stub"); + } + + public long maxOneTimeKeys() { + throw new RuntimeException("stub"); + + } + + public void generateOneTimeKeys(int aNumberOfKeys) throws OlmException { + throw new RuntimeException("stub"); + } + + public Map> oneTimeKeys() throws OlmException { + throw new RuntimeException("stub"); + } + + public void removeOneTimeKeys(OlmSession aSession) throws OlmException { + throw new RuntimeException("stub"); + } + + public void markOneTimeKeysAsPublished() throws OlmException { + throw new RuntimeException("stub"); + } + + public String signMessage(String aMessage) throws OlmException { + throw new RuntimeException("stub"); + } + + protected byte[] serialize(byte[] aKey, StringBuffer aErrorMsg) { + throw new RuntimeException("stub"); + } + + protected void deserialize(byte[] aSerializedData, byte[] aKey) throws Exception { + throw new RuntimeException("stub"); + } + + public byte[] pickle(byte[] aKey, StringBuffer aErrorMsg) { + throw new RuntimeException("stub"); + + } + + public void unpickle(byte[] aSerializedData, byte[] aKey) throws Exception { + throw new RuntimeException("stub"); + } + + public void generateFallbackKey() throws OlmException { + throw new RuntimeException("stub"); + } + + public Map> fallbackKey() throws OlmException { + throw new RuntimeException("stub"); + } + + public void forgetFallbackKey() throws OlmException { + throw new RuntimeException("stub"); + } + +} diff --git a/domains/olm-stub/src/main/java/org/matrix/olm/OlmException.java b/domains/olm-stub/src/main/java/org/matrix/olm/OlmException.java new file mode 100644 index 0000000..9b693a7 --- /dev/null +++ b/domains/olm-stub/src/main/java/org/matrix/olm/OlmException.java @@ -0,0 +1,70 @@ +package org.matrix.olm; + +import java.io.IOException; + +public class OlmException extends IOException { + public static final int EXCEPTION_CODE_INIT_ACCOUNT_CREATION = 10; + public static final int EXCEPTION_CODE_ACCOUNT_SERIALIZATION = 100; + public static final int EXCEPTION_CODE_ACCOUNT_DESERIALIZATION = 101; + public static final int EXCEPTION_CODE_ACCOUNT_IDENTITY_KEYS = 102; + public static final int EXCEPTION_CODE_ACCOUNT_GENERATE_ONE_TIME_KEYS = 103; + public static final int EXCEPTION_CODE_ACCOUNT_ONE_TIME_KEYS = 104; + public static final int EXCEPTION_CODE_ACCOUNT_REMOVE_ONE_TIME_KEYS = 105; + public static final int EXCEPTION_CODE_ACCOUNT_MARK_ONE_KEYS_AS_PUBLISHED = 106; + public static final int EXCEPTION_CODE_ACCOUNT_SIGN_MESSAGE = 107; + public static final int EXCEPTION_CODE_ACCOUNT_GENERATE_FALLBACK_KEY = 108; + public static final int EXCEPTION_CODE_ACCOUNT_FALLBACK_KEY = 109; + public static final int EXCEPTION_CODE_ACCOUNT_FORGET_FALLBACK_KEY = 110; + public static final int EXCEPTION_CODE_CREATE_INBOUND_GROUP_SESSION = 200; + public static final int EXCEPTION_CODE_INIT_INBOUND_GROUP_SESSION = 201; + public static final int EXCEPTION_CODE_INBOUND_GROUP_SESSION_IDENTIFIER = 202; + public static final int EXCEPTION_CODE_INBOUND_GROUP_SESSION_DECRYPT_SESSION = 203; + public static final int EXCEPTION_CODE_INBOUND_GROUP_SESSION_FIRST_KNOWN_INDEX = 204; + public static final int EXCEPTION_CODE_INBOUND_GROUP_SESSION_IS_VERIFIED = 205; + public static final int EXCEPTION_CODE_INBOUND_GROUP_SESSION_EXPORT = 206; + public static final int EXCEPTION_CODE_CREATE_OUTBOUND_GROUP_SESSION = 300; + public static final int EXCEPTION_CODE_INIT_OUTBOUND_GROUP_SESSION = 301; + public static final int EXCEPTION_CODE_OUTBOUND_GROUP_SESSION_IDENTIFIER = 302; + public static final int EXCEPTION_CODE_OUTBOUND_GROUP_SESSION_KEY = 303; + public static final int EXCEPTION_CODE_OUTBOUND_GROUP_ENCRYPT_MESSAGE = 304; + public static final int EXCEPTION_CODE_INIT_SESSION_CREATION = 400; + public static final int EXCEPTION_CODE_SESSION_INIT_OUTBOUND_SESSION = 401; + public static final int EXCEPTION_CODE_SESSION_INIT_INBOUND_SESSION = 402; + public static final int EXCEPTION_CODE_SESSION_INIT_INBOUND_SESSION_FROM = 403; + public static final int EXCEPTION_CODE_SESSION_ENCRYPT_MESSAGE = 404; + public static final int EXCEPTION_CODE_SESSION_DECRYPT_MESSAGE = 405; + public static final int EXCEPTION_CODE_SESSION_SESSION_IDENTIFIER = 406; + public static final int EXCEPTION_CODE_UTILITY_CREATION = 500; + public static final int EXCEPTION_CODE_UTILITY_VERIFY_SIGNATURE = 501; + public static final int EXCEPTION_CODE_PK_ENCRYPTION_CREATION = 600; + public static final int EXCEPTION_CODE_PK_ENCRYPTION_SET_RECIPIENT_KEY = 601; + public static final int EXCEPTION_CODE_PK_ENCRYPTION_ENCRYPT = 602; + public static final int EXCEPTION_CODE_PK_DECRYPTION_CREATION = 700; + public static final int EXCEPTION_CODE_PK_DECRYPTION_GENERATE_KEY = 701; + public static final int EXCEPTION_CODE_PK_DECRYPTION_DECRYPT = 702; + public static final int EXCEPTION_CODE_PK_DECRYPTION_SET_PRIVATE_KEY = 703; + public static final int EXCEPTION_CODE_PK_DECRYPTION_PRIVATE_KEY = 704; + public static final int EXCEPTION_CODE_PK_SIGNING_CREATION = 800; + public static final int EXCEPTION_CODE_PK_SIGNING_GENERATE_SEED = 801; + public static final int EXCEPTION_CODE_PK_SIGNING_INIT_WITH_SEED = 802; + public static final int EXCEPTION_CODE_PK_SIGNING_SIGN = 803; + public static final int EXCEPTION_CODE_SAS_CREATION = 900; + public static final int EXCEPTION_CODE_SAS_ERROR = 901; + public static final int EXCEPTION_CODE_SAS_MISSING_THEIR_PKEY = 902; + public static final int EXCEPTION_CODE_SAS_GENERATE_SHORT_CODE = 903; + public static final String EXCEPTION_MSG_INVALID_PARAMS_DESERIALIZATION = "invalid de-serialized parameters"; + private final int mCode; + private final String mMessage; + + public OlmException(int aExceptionCode, String aExceptionMessage) { + throw new RuntimeException("stub"); + } + + public int getExceptionCode() { + throw new RuntimeException("stub"); + } + + public String getMessage() { + throw new RuntimeException("stub"); + } +} diff --git a/domains/olm-stub/src/main/java/org/matrix/olm/OlmInboundGroupSession.java b/domains/olm-stub/src/main/java/org/matrix/olm/OlmInboundGroupSession.java new file mode 100644 index 0000000..fc1c969 --- /dev/null +++ b/domains/olm-stub/src/main/java/org/matrix/olm/OlmInboundGroupSession.java @@ -0,0 +1,59 @@ +package org.matrix.olm; + +import java.io.Serializable; + +public class OlmInboundGroupSession implements Serializable { + + public OlmInboundGroupSession(String aSessionKey) throws OlmException { + throw new RuntimeException("stub"); + } + + public static OlmInboundGroupSession importSession(String exported) throws OlmException { + throw new RuntimeException("stub"); + } + + public void releaseSession() { + throw new RuntimeException("stub"); + } + + public boolean isReleased() { + throw new RuntimeException("stub"); + } + + public String sessionIdentifier() throws OlmException { + throw new RuntimeException("stub"); + } + + public long getFirstKnownIndex() throws OlmException { + throw new RuntimeException("stub"); + } + + public boolean isVerified() throws OlmException { + throw new RuntimeException("stub"); + } + + public String export(long messageIndex) throws OlmException { + throw new RuntimeException("stub"); + } + + public OlmInboundGroupSession.DecryptMessageResult decryptMessage(String aEncryptedMsg) throws OlmException { + throw new RuntimeException("stub"); + } + + protected byte[] serialize(byte[] aKey, StringBuffer aErrorMsg) { + throw new RuntimeException("stub"); + } + + protected void deserialize(byte[] aSerializedData, byte[] aKey) throws Exception { + throw new RuntimeException("stub"); + } + + public static class DecryptMessageResult { + public String mDecryptedMessage; + public long mIndex; + + public DecryptMessageResult() { + throw new RuntimeException("stub"); + } + } +} diff --git a/domains/olm-stub/src/main/java/org/matrix/olm/OlmManager.java b/domains/olm-stub/src/main/java/org/matrix/olm/OlmManager.java new file mode 100644 index 0000000..30a3676 --- /dev/null +++ b/domains/olm-stub/src/main/java/org/matrix/olm/OlmManager.java @@ -0,0 +1,14 @@ +package org.matrix.olm; + +public class OlmManager { + public OlmManager() { + throw new RuntimeException("stub"); + } + + public String getOlmLibVersion() { + throw new RuntimeException("stub"); + } + + public native String getOlmLibVersionJni(); + +} diff --git a/domains/olm-stub/src/main/java/org/matrix/olm/OlmMessage.java b/domains/olm-stub/src/main/java/org/matrix/olm/OlmMessage.java new file mode 100644 index 0000000..e95dc17 --- /dev/null +++ b/domains/olm-stub/src/main/java/org/matrix/olm/OlmMessage.java @@ -0,0 +1,12 @@ +package org.matrix.olm; + +public class OlmMessage { + public static final int MESSAGE_TYPE_PRE_KEY = 0; + public static final int MESSAGE_TYPE_MESSAGE = 1; + public String mCipherText; + public long mType; + + public OlmMessage() { + throw new RuntimeException("stub"); + } +} diff --git a/domains/olm-stub/src/main/java/org/matrix/olm/OlmOutboundGroupSession.java b/domains/olm-stub/src/main/java/org/matrix/olm/OlmOutboundGroupSession.java new file mode 100644 index 0000000..05e4986 --- /dev/null +++ b/domains/olm-stub/src/main/java/org/matrix/olm/OlmOutboundGroupSession.java @@ -0,0 +1,43 @@ +package org.matrix.olm; + +import java.io.Serializable; + +public class OlmOutboundGroupSession implements Serializable { + + public OlmOutboundGroupSession() throws OlmException { + throw new RuntimeException("stub"); + } + + public void releaseSession() { + throw new RuntimeException("stub"); + } + + public boolean isReleased() { + throw new RuntimeException("stub"); + } + + public String sessionIdentifier() throws OlmException { + throw new RuntimeException("stub"); + } + + public int messageIndex() { + throw new RuntimeException("stub"); + } + + public String sessionKey() throws OlmException { + throw new RuntimeException("stub"); + } + + public String encryptMessage(String aClearMsg) throws OlmException { + throw new RuntimeException("stub"); + } + + protected byte[] serialize(byte[] aKey, StringBuffer aErrorMsg) { + throw new RuntimeException("stub"); + } + + protected void deserialize(byte[] aSerializedData, byte[] aKey) throws Exception { + throw new RuntimeException("stub"); + } + +} diff --git a/domains/olm-stub/src/main/java/org/matrix/olm/OlmSAS.java b/domains/olm-stub/src/main/java/org/matrix/olm/OlmSAS.java new file mode 100644 index 0000000..b2404e1 --- /dev/null +++ b/domains/olm-stub/src/main/java/org/matrix/olm/OlmSAS.java @@ -0,0 +1,32 @@ +package org.matrix.olm; + +public class OlmSAS { + + public OlmSAS() throws OlmException { + throw new RuntimeException("stub"); + } + + public String getPublicKey() throws OlmException { + throw new RuntimeException("stub"); + } + + public void setTheirPublicKey(String otherPkey) throws OlmException { + throw new RuntimeException("stub"); + } + + public byte[] generateShortCode(String info, int byteNumber) throws OlmException { + throw new RuntimeException("stub"); + } + + public String calculateMac(String message, String info) throws OlmException { + throw new RuntimeException("stub"); + } + + public String calculateMacLongKdf(String message, String info) throws OlmException { + throw new RuntimeException("stub"); + } + + public void releaseSas() { + throw new RuntimeException("stub"); + } +} diff --git a/domains/olm-stub/src/main/java/org/matrix/olm/OlmSession.java b/domains/olm-stub/src/main/java/org/matrix/olm/OlmSession.java new file mode 100644 index 0000000..4200e30 --- /dev/null +++ b/domains/olm-stub/src/main/java/org/matrix/olm/OlmSession.java @@ -0,0 +1,63 @@ +package org.matrix.olm; + +import java.io.Serializable; + +public class OlmSession implements Serializable { + + public OlmSession() throws OlmException { + throw new RuntimeException("stub"); + } + + long getOlmSessionId() { + throw new RuntimeException("stub"); + } + + public void releaseSession() { + throw new RuntimeException("stub"); + } + + public boolean isReleased() { + throw new RuntimeException("stub"); + } + + public void initOutboundSession(OlmAccount aAccount, String aTheirIdentityKey, String aTheirOneTimeKey) throws OlmException { + throw new RuntimeException("stub"); + } + + public void initInboundSession(OlmAccount aAccount, String aPreKeyMsg) throws OlmException { + throw new RuntimeException("stub"); + } + + public void initInboundSessionFrom(OlmAccount aAccount, String aTheirIdentityKey, String aPreKeyMsg) throws OlmException { + throw new RuntimeException("stub"); + } + + public String sessionIdentifier() throws OlmException { + throw new RuntimeException("stub"); + } + + public boolean matchesInboundSession(String aOneTimeKeyMsg) { + throw new RuntimeException("stub"); + } + + public boolean matchesInboundSessionFrom(String aTheirIdentityKey, String aOneTimeKeyMsg) { + throw new RuntimeException("stub"); + } + + public OlmMessage encryptMessage(String aClearMsg) throws OlmException { + throw new RuntimeException("stub"); + } + + public String decryptMessage(OlmMessage aEncryptedMsg) throws OlmException { + throw new RuntimeException("stub"); + } + + protected byte[] serialize(byte[] aKey, StringBuffer aErrorMsg) { + throw new RuntimeException("stub"); + + } + + protected void deserialize(byte[] aSerializedData, byte[] aKey) throws Exception { + throw new RuntimeException("stub"); + } +} diff --git a/domains/olm-stub/src/main/java/org/matrix/olm/OlmUtility.java b/domains/olm-stub/src/main/java/org/matrix/olm/OlmUtility.java new file mode 100644 index 0000000..e15d2c7 --- /dev/null +++ b/domains/olm-stub/src/main/java/org/matrix/olm/OlmUtility.java @@ -0,0 +1,41 @@ +package org.matrix.olm; + +import org.json.JSONObject; + +import java.util.Map; + +public class OlmUtility { + public static final int RANDOM_KEY_SIZE = 32; + + public OlmUtility() throws OlmException { + throw new RuntimeException("stub"); + } + + public void releaseUtility() { + throw new RuntimeException("stub"); + } + + public void verifyEd25519Signature(String aSignature, String aFingerprintKey, String aMessage) throws OlmException { + throw new RuntimeException("stub"); + } + + public String sha256(String aMessageToHash) { + throw new RuntimeException("stub"); + } + + public static byte[] getRandomKey() { + throw new RuntimeException("stub"); + } + + public boolean isReleased() { + throw new RuntimeException("stub"); + } + + public static Map toStringMap(JSONObject jsonObject) { + throw new RuntimeException("stub"); + } + + public static Map> toStringMapMap(JSONObject jsonObject) { + throw new RuntimeException("stub"); + } +} diff --git a/domains/olm/build.gradle b/domains/olm/build.gradle new file mode 100644 index 0000000..ca4ba3e --- /dev/null +++ b/domains/olm/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'kotlin' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation Dependencies.mavenCentral.kotlinSerializationJson + implementation Dependencies.mavenCentral.kotlinCoroutinesCore + + implementation project(":core") + implementation project(":domains:store") + implementation project(":matrix:services:crypto") + implementation project(":matrix:services:device") + compileOnly project(":domains:olm-stub") +} \ No newline at end of file diff --git a/domains/olm/src/main/kotlin/app/dapk/st/olm/DefaultSasSession.kt b/domains/olm/src/main/kotlin/app/dapk/st/olm/DefaultSasSession.kt new file mode 100644 index 0000000..8a90246 --- /dev/null +++ b/domains/olm/src/main/kotlin/app/dapk/st/olm/DefaultSasSession.kt @@ -0,0 +1,53 @@ +package app.dapk.st.olm + +import app.dapk.st.matrix.common.DeviceId +import app.dapk.st.matrix.common.Ed25519 +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.crypto.Olm +import org.matrix.olm.OlmSAS +import org.matrix.olm.OlmUtility + +internal class DefaultSasSession(private val selfFingerprint: Ed25519) : Olm.SasSession { + + private val olmSAS = OlmSAS() + + override fun publicKey(): String { + return olmSAS.publicKey + } + + override suspend fun generateCommitment(hash: String, startJsonString: String): String { + val utility = OlmUtility() + return utility.sha256(olmSAS.publicKey + startJsonString).also { + utility.releaseUtility() + } + } + + override suspend fun calculateMac( + selfUserId: UserId, + selfDeviceId: DeviceId, + otherUserId: UserId, + otherDeviceId: DeviceId, + transactionId: String + ): Olm.MacResult { + val baseInfo = "MATRIX_KEY_VERIFICATION_MAC" + + selfUserId.value + + selfDeviceId.value + + otherUserId.value + + otherDeviceId.value + + transactionId + val deviceKeyId = "ed25519:${selfDeviceId.value}" + val macMap = mapOf( + deviceKeyId to olmSAS.calculateMac(selfFingerprint.value, baseInfo + deviceKeyId) + ) + val keys = olmSAS.calculateMac(macMap.keys.sorted().joinToString(separator = ","), baseInfo + "KEY_IDS") + return Olm.MacResult(macMap, keys) + } + + override fun setTheirPublicKey(key: String) { + olmSAS.setTheirPublicKey(key) + } + + override fun release() { + olmSAS.releaseSas() + } +} \ No newline at end of file diff --git a/domains/olm/src/main/kotlin/app/dapk/st/olm/DeviceKeyFactory.kt b/domains/olm/src/main/kotlin/app/dapk/st/olm/DeviceKeyFactory.kt new file mode 100644 index 0000000..202154e --- /dev/null +++ b/domains/olm/src/main/kotlin/app/dapk/st/olm/DeviceKeyFactory.kt @@ -0,0 +1,39 @@ +package app.dapk.st.olm + +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.common.extensions.toJsonString +import app.dapk.st.matrix.crypto.Olm +import app.dapk.st.matrix.device.internal.DeviceKeys +import org.matrix.olm.OlmAccount + +class DeviceKeyFactory( + private val jsonCanonicalizer: JsonCanonicalizer, +) { + + fun create(userId: UserId, deviceId: DeviceId, identityKey: Ed25519, senderKey: Curve25519, olmAccount: OlmAccount): DeviceKeys { + val signable = mapOf( + "device_id" to deviceId.value, + "user_id" to userId.value, + "algorithms" to listOf(Olm.ALGORITHM_MEGOLM.value, Olm.ALGORITHM_OLM.value), + "keys" to mapOf( + "curve25519:${deviceId.value}" to senderKey.value, + "ed25519:${deviceId.value}" to identityKey.value, + ) + ).toJsonString() + + return DeviceKeys( + userId, + deviceId, + algorithms = listOf(Olm.ALGORITHM_MEGOLM, Olm.ALGORITHM_OLM), + keys = mapOf( + "curve25519:${deviceId.value}" to senderKey.value, + "ed25519:${deviceId.value}" to identityKey.value, + ), + signatures = mapOf( + userId.value to mapOf( + "ed25519:${deviceId.value}" to olmAccount.signMessage(jsonCanonicalizer.canonicalize(signable)) + ) + ) + ) + } +} \ No newline at end of file diff --git a/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmExtensions.kt b/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmExtensions.kt new file mode 100644 index 0000000..c26554b --- /dev/null +++ b/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmExtensions.kt @@ -0,0 +1,10 @@ +package app.dapk.st.olm + +import app.dapk.st.matrix.common.Curve25519 +import app.dapk.st.matrix.common.Ed25519 +import org.matrix.olm.OlmAccount + +fun OlmAccount.readIdentityKeys(): Pair { + val identityKeys = this.identityKeys() + return Ed25519(identityKeys["ed25519"]!!) to Curve25519(identityKeys["curve25519"]!!) +} diff --git a/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmPersistenceWrapper.kt b/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmPersistenceWrapper.kt new file mode 100644 index 0000000..36869c1 --- /dev/null +++ b/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmPersistenceWrapper.kt @@ -0,0 +1,69 @@ +package app.dapk.st.olm + +import app.dapk.st.domain.OlmPersistence +import app.dapk.st.domain.SerializedObject +import app.dapk.st.matrix.common.Curve25519 +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.SessionId +import org.matrix.olm.OlmAccount +import org.matrix.olm.OlmInboundGroupSession +import org.matrix.olm.OlmOutboundGroupSession +import org.matrix.olm.OlmSession +import java.io.* +import java.util.* + +class OlmPersistenceWrapper( + private val olmPersistence: OlmPersistence, +) : OlmStore { + + override suspend fun read(): OlmAccount? { + return olmPersistence.read()?.deserialize() + } + + override suspend fun persist(olmAccount: OlmAccount) { + olmPersistence.persist(SerializedObject(olmAccount.serialize())) + } + + override suspend fun readOutbound(roomId: RoomId): Pair? { + return olmPersistence.readOutbound(roomId)?.let { + it.first to it.second.deserialize() + } + } + + override suspend fun persistOutbound(roomId: RoomId, creationTimestampUtc: Long, outboundGroupSession: OlmOutboundGroupSession) { + olmPersistence.persistOutbound(roomId, creationTimestampUtc, SerializedObject(outboundGroupSession.serialize())) + } + + override suspend fun persistSession(identity: Curve25519, sessionId: SessionId, olmSession: OlmSession) { + olmPersistence.persistSession(identity, sessionId, SerializedObject(olmSession.serialize())) + } + + override suspend fun readSessions(identities: List): List>? { + return olmPersistence.readSessions(identities)?.map { it.first to it.second.deserialize() } + } + + override suspend fun persist(sessionId: SessionId, inboundGroupSession: OlmInboundGroupSession) { + olmPersistence.persist(sessionId, SerializedObject(inboundGroupSession.serialize())) + } + + override suspend fun readInbound(sessionId: SessionId): OlmInboundGroupSession? { + return olmPersistence.readInbound(sessionId)?.value?.deserialize() + } +} + +private fun T.serialize(): String { + val baos = ByteArrayOutputStream() + ObjectOutputStream(baos).use { + it.writeObject(this) + } + return Base64.getEncoder().encode(baos.toByteArray()).toString(Charsets.UTF_8) +} + +@Suppress("UNCHECKED_CAST") +private fun String.deserialize(): T { + val decoded = Base64.getDecoder().decode(this) + val baos = ByteArrayInputStream(decoded) + return ObjectInputStream(baos).use { + it.readObject() as T + } +} diff --git a/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmStore.kt b/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmStore.kt new file mode 100644 index 0000000..175e3cd --- /dev/null +++ b/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmStore.kt @@ -0,0 +1,21 @@ +package app.dapk.st.olm + +import app.dapk.st.matrix.common.Curve25519 +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.SessionId +import org.matrix.olm.OlmAccount +import org.matrix.olm.OlmInboundGroupSession +import org.matrix.olm.OlmOutboundGroupSession +import org.matrix.olm.OlmSession + +interface OlmStore { + suspend fun read(): OlmAccount? + suspend fun persist(olmAccount: OlmAccount) + + suspend fun readOutbound(roomId: RoomId): Pair? + suspend fun persistOutbound(roomId: RoomId, creationTimestampUtc: Long, outboundGroupSession: OlmOutboundGroupSession) + suspend fun persistSession(identity: Curve25519, sessionId: SessionId, olmSession: OlmSession) + suspend fun readSessions(identities: List): List>? + suspend fun persist(sessionId: SessionId, inboundGroupSession: OlmInboundGroupSession) + suspend fun readInbound(sessionId: SessionId): OlmInboundGroupSession? +} \ No newline at end of file diff --git a/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmWrapper.kt b/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmWrapper.kt new file mode 100644 index 0000000..6b11027 --- /dev/null +++ b/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmWrapper.kt @@ -0,0 +1,375 @@ +package app.dapk.st.olm + +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.SingletonFlows +import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.core.extensions.ifNull +import app.dapk.st.core.withIoContext +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.common.MatrixLogTag.CRYPTO +import app.dapk.st.matrix.crypto.Olm +import app.dapk.st.matrix.crypto.Olm.* +import app.dapk.st.matrix.device.DeviceService +import app.dapk.st.matrix.device.internal.DeviceKeys +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.matrix.olm.* +import java.time.Clock + +private const val SEVEN_DAYS_MILLIS = 604800000 +private const val MEGOLM_ROTATION_MESSAGE_COUNT = 100 +private const val INIT_OLM = "init-olm" + +class OlmWrapper( + private val olmStore: OlmStore, + private val singletonFlows: SingletonFlows, + private val jsonCanonicalizer: JsonCanonicalizer, + private val deviceKeyFactory: DeviceKeyFactory, + private val errorTracker: ErrorTracker, + private val logger: MatrixLogger, + private val clock: Clock, + coroutineDispatchers: CoroutineDispatchers +) : Olm { + + init { + coroutineDispatchers.global.launch { + coroutineDispatchers.withIoContext { + singletonFlows.getOrPut(INIT_OLM) { + OlmManager() + }.collect() + } + } + } + + override suspend fun import(keys: List) { + interactWithOlm() + keys.forEach { + val inBound = when (it.isExported) { + true -> OlmInboundGroupSession.importSession(it.sessionKey) + false -> OlmInboundGroupSession(it.sessionKey) + } + logger.crypto("import megolm ${it.sessionKey}") + olmStore.persist(it.sessionId, inBound) + } + } + + override suspend fun ensureAccountCrypto(deviceCredentials: DeviceCredentials, onCreate: suspend (AccountCryptoSession) -> Unit): AccountCryptoSession { + interactWithOlm() + return singletonFlows.getOrPut("account-crypto") { + accountCrypto(deviceCredentials) ?: createAccountCrypto(deviceCredentials, onCreate) + }.first() + } + + private suspend fun accountCrypto(deviceCredentials: DeviceCredentials): AccountCryptoSession? { + return olmStore.read()?.let { olmAccount -> + createAccountCryptoSession(deviceCredentials, olmAccount) + } + } + + override suspend fun AccountCryptoSession.generateOneTimeKeys( + count: Int, + credentials: DeviceCredentials, + publishKeys: suspend (DeviceService.OneTimeKeys) -> Unit + ) { + interactWithOlm() + val olmAccount = this.olmAccount as OlmAccount + olmAccount.generateOneTimeKeys(count) + + val oneTimeKeys = DeviceService.OneTimeKeys(olmAccount.oneTimeKeys()["curve25519"]!!.map { + DeviceService.OneTimeKeys.Key.SignedCurve( + keyId = it.key, + value = it.value, + signature = DeviceService.OneTimeKeys.Key.SignedCurve.Ed25519Signature( + value = it.value.toSignedJson(olmAccount), + deviceId = credentials.deviceId, + userId = credentials.userId, + ) + ) + }) + publishKeys(oneTimeKeys) + olmAccount.markOneTimeKeysAsPublished() + updateAccountInstance(olmAccount) + } + + private suspend fun createAccountCrypto(deviceCredentials: DeviceCredentials, action: suspend (AccountCryptoSession) -> Unit): AccountCryptoSession { + val olmAccount = OlmAccount() + return createAccountCryptoSession(deviceCredentials, olmAccount).also { + action(it) + olmStore.persist(olmAccount) + } + } + + private fun createAccountCryptoSession(credentials: DeviceCredentials, olmAccount: OlmAccount): AccountCryptoSession { + val (identityKey, senderKey) = olmAccount.readIdentityKeys() + return AccountCryptoSession( + fingerprint = identityKey, + senderKey = senderKey, + deviceKeys = deviceKeyFactory.create(credentials.userId, credentials.deviceId, identityKey, senderKey, olmAccount), + olmAccount = olmAccount, + maxKeys = olmAccount.maxOneTimeKeys().toInt() + ) + } + + override suspend fun ensureRoomCrypto( + roomId: RoomId, + accountSession: AccountCryptoSession, + ): RoomCryptoSession { + interactWithOlm() + return singletonFlows.getOrPut("room-${roomId.value}") { + roomCrypto(roomId, accountSession) ?: createRoomCrypto(roomId, accountSession) + } + .first() + .maybeRotateRoomSession(roomId, accountSession) + } + + private suspend fun RoomCryptoSession.maybeRotateRoomSession(roomId: RoomId, accountSession: AccountCryptoSession): RoomCryptoSession { + val now = clock.millis() + return when { + this.messageIndex > MEGOLM_ROTATION_MESSAGE_COUNT || (now - this.creationTimestampUtc) > SEVEN_DAYS_MILLIS -> { + logger.matrixLog(CRYPTO, "rotating megolm for room ${roomId.value}") + createRoomCrypto(roomId, accountSession).also { rotatedSession -> + singletonFlows.update("room-${roomId.value}", rotatedSession) + } + } + else -> this + } + } + + private suspend fun roomCrypto(roomId: RoomId, accountCryptoSession: AccountCryptoSession): RoomCryptoSession? { + return olmStore.readOutbound(roomId)?.let { (timestampUtc, outBound) -> + RoomCryptoSession( + creationTimestampUtc = timestampUtc, + key = outBound.sessionKey(), + messageIndex = outBound.messageIndex(), + accountCryptoSession = accountCryptoSession, + id = SessionId(outBound.sessionIdentifier()), + outBound = outBound + ) + } + } + + private suspend fun createRoomCrypto(roomId: RoomId, accountCryptoSession: AccountCryptoSession): RoomCryptoSession { + val outBound = OlmOutboundGroupSession() + val roomCryptoSession = RoomCryptoSession( + creationTimestampUtc = clock.millis(), + key = outBound.sessionKey(), + messageIndex = outBound.messageIndex(), + accountCryptoSession = accountCryptoSession, + id = SessionId(outBound.sessionIdentifier()), + outBound = outBound + ) + olmStore.persistOutbound(roomId, roomCryptoSession.creationTimestampUtc, outBound) + + val inBound = OlmInboundGroupSession(roomCryptoSession.key) + olmStore.persist(roomCryptoSession.id, inBound) + + return roomCryptoSession + } + + override suspend fun ensureDeviceCrypto(input: OlmSessionInput, olmAccount: AccountCryptoSession): DeviceCryptoSession { + interactWithOlm() + return deviceCrypto(input) ?: createDeviceCrypto(olmAccount, input) + } + + private suspend fun deviceCrypto(input: OlmSessionInput): DeviceCryptoSession? { + return olmStore.readSessions(listOf(input.identity))?.let { + DeviceCryptoSession( + input.deviceId, input.userId, input.identity, input.fingerprint, it + ) + } + } + + private suspend fun createDeviceCrypto(accountCryptoSession: AccountCryptoSession, input: OlmSessionInput): DeviceCryptoSession { + val olmSession = OlmSession() + olmSession.initOutboundSession(accountCryptoSession.olmAccount as OlmAccount, input.identity.value, input.oneTimeKey) + val sessionId = SessionId(olmSession.sessionIdentifier()) + logger.crypto("creating olm session: $sessionId ${input.identity} ${input.userId} ${input.deviceId}") + olmStore.persistSession(input.identity, sessionId, olmSession) + return DeviceCryptoSession(input.deviceId, input.userId, input.identity, input.fingerprint, listOf(olmSession)) + } + + @Suppress("UNCHECKED_CAST") + override suspend fun DeviceCryptoSession.encrypt(messageJson: JsonString): EncryptionResult { + interactWithOlm() + val olmSession = this.olmSession as List + + logger.crypto("encrypting with session(s) ${olmSession.size}") + + val (result, session) = olmSession.firstNotNullOf { + kotlin.runCatching { + it.encryptMessage(jsonCanonicalizer.canonicalize(messageJson)) to it + }.getOrNull() + } + + logger.crypto("encrypt flow identity: ${this.identity}") + olmStore.persistSession(this.identity, SessionId(session.sessionIdentifier()), session) + return EncryptionResult( + cipherText = CipherText(result.mCipherText), + type = result.mType, + ) + } + + override suspend fun RoomCryptoSession.encrypt(roomId: RoomId, messageJson: JsonString): CipherText { + interactWithOlm() + val messagePayloadString = jsonCanonicalizer.canonicalize(messageJson) + val outBound = this.outBound as OlmOutboundGroupSession + val encryptedMessage = CipherText(outBound.encryptMessage(messagePayloadString)) + singletonFlows.update( + "room-${roomId.value}", + this.copy(outBound = outBound, messageIndex = outBound.messageIndex()) + ) + + olmStore.persistOutbound(roomId, this.creationTimestampUtc, outBound) + return encryptedMessage + } + + private fun String.toSignedJson(olmAccount: OlmAccount): SignedJson { + val json = JsonString(Json.encodeToString(mapOf("key" to this))) + return SignedJson(olmAccount.signMessage(jsonCanonicalizer.canonicalize(json))) + } + + override suspend fun decryptOlm(olmAccount: AccountCryptoSession, senderKey: Curve25519, type: Long, body: CipherText): DecryptionResult { + interactWithOlm() + val olmMessage = OlmMessage().apply { + this.mType = type + this.mCipherText = body.value + } + + val readSession = olmStore.readSessions(listOf(senderKey)).let { + if (it == null) { + logger.crypto("no olm session found for $senderKey, creating a new one") + listOf(senderKey to OlmSession()) + } else { + logger.crypto("found olm session(s) ${it.size}") + it.forEach { + logger.crypto("${it.first} ${it.second.sessionIdentifier()}") + } + it + } + } + val errors = mutableListOf() + + return readSession.firstNotNullOfOrNull { (_, session) -> + kotlin.runCatching { + when (type.toInt()) { + OlmMessage.MESSAGE_TYPE_PRE_KEY -> { + if (session.matchesInboundSession(body.value)) { + logger.matrixLog(CRYPTO, "matched inbound session, attempting decrypt") + session.decryptMessage(olmMessage)?.let { JsonString(it) } + } else { + logger.matrixLog(CRYPTO, "prekey has no inbound session, doing alternative flow") + val account = olmAccount.olmAccount as OlmAccount + + val session = OlmSession() + session.initInboundSessionFrom(account, senderKey.value, body.value) + account.removeOneTimeKeys(session) + olmAccount.updateAccountInstance(account) + session.decryptMessage(olmMessage)?.let { JsonString(it) }?.also { + logger.crypto("alt flow identity: $senderKey : ${session.sessionIdentifier()}") + olmStore.persistSession(senderKey, SessionId(session.sessionIdentifier()), session) + } + } + } + OlmMessage.MESSAGE_TYPE_MESSAGE -> { + logger.crypto("decrypting olm message type") + session.decryptMessage(olmMessage)?.let { JsonString(it) } + } + else -> throw IllegalArgumentException("Unknown message type: $type") + } + }.onFailure { + errors.add(it) + logger.crypto("error code: ${(it as? OlmException)?.exceptionCode}") + errorTracker.track(it, "failed to decrypt olm") + }.getOrNull()?.let { DecryptionResult.Success(it, isVerified = false) } + }.ifNull { + logger.matrixLog(CRYPTO, "failed to decrypt olm session") + DecryptionResult.Failed(errors.joinToString { it.message ?: "N/A" }) + } + } + + private suspend fun AccountCryptoSession.updateAccountInstance(olmAccount: OlmAccount) { + singletonFlows.update("account-crypto", this.copy(olmAccount = olmAccount)) + olmStore.persist(olmAccount) + } + + override suspend fun decryptMegOlm(sessionId: SessionId, cipherText: CipherText): DecryptionResult { + interactWithOlm() + return when (val megolmSession = olmStore.readInbound(sessionId)) { + null -> DecryptionResult.Failed("no megolm session found for id: $sessionId") + else -> { + runCatching { + JsonString(megolmSession.decryptMessage(cipherText.value).mDecryptedMessage).also { + olmStore.persist(sessionId, megolmSession) + } + }.fold( + onSuccess = { DecryptionResult.Success(it, isVerified = false) }, + onFailure = { + errorTracker.track(it) + DecryptionResult.Failed(it.message ?: "Unknown") + } + ) + } + } + } + + override suspend fun verifyExternalUser(keys: Ed25519?, recipeientKeys: Ed25519?): Boolean { + return false + } + + private suspend fun interactWithOlm() = singletonFlows.get(INIT_OLM).first() + + override suspend fun olmSessions(devices: List, onMissing: suspend (List) -> List): List { + interactWithOlm() + + val inputByIdentity = devices.groupBy { it.keys().first } + val inputByKeys = devices.associateBy { it.keys() } + + val inputs = inputByKeys.map { (keys, deviceKeys) -> + val (identity, fingerprint) = keys + Olm.OlmSessionInput(oneTimeKey = "ignored", identity = identity, deviceKeys.deviceId, deviceKeys.userId, fingerprint) + } + + val requestedIdentities = inputs.map { it.identity } + val foundSessions = olmStore.readSessions(requestedIdentities) ?: emptyList() + val foundSessionsByIdentity = foundSessions.groupBy { it.first } + + val foundSessionIdentities = foundSessions.map { it.first } + val missingIdentities = requestedIdentities - foundSessionIdentities.toSet() + + val newOlmSessions = if (missingIdentities.isNotEmpty()) { + onMissing(missingIdentities.map { inputByIdentity[it]!! }.flatten()) + } else emptyList() + + return (inputs.filterNot { missingIdentities.contains(it.identity) }.map { + val olmSession = foundSessionsByIdentity[it.identity]!!.map { it.second } + + logger.crypto("found ${olmSession.size} olm session(s) for ${it.identity}") + olmSession.forEach { + logger.crypto(it.sessionIdentifier()) + } + + DeviceCryptoSession( + deviceId = it.deviceId, + userId = it.userId, + identity = it.identity, + fingerprint = it.fingerprint, + olmSession = olmSession + ) + }) + newOlmSessions + } + + override suspend fun sasSession(deviceCredentials: DeviceCredentials): SasSession { + val account = ensureAccountCrypto(deviceCredentials, onCreate = {}) + return DefaultSasSession(account.fingerprint) + } +} + + +private fun DeviceKeys.keys(): Pair { + val identity = Curve25519(this.keys.filter { it.key.startsWith("curve25519:") }.values.first()) + val fingerprint = Ed25519(this.keys.filter { it.key.startsWith("ed25519:") }.values.first()) + return identity to fingerprint +} diff --git a/domains/store/build.gradle b/domains/store/build.gradle new file mode 100644 index 0000000..325a858 --- /dev/null +++ b/domains/store/build.gradle @@ -0,0 +1,25 @@ +plugins { + id 'kotlin' + id 'com.squareup.sqldelight' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +sqldelight { + DapkDb { + packageName = "app.dapk.db" + } + linkSqlite = true +} + +dependencies { + api project(":matrix:common") + implementation project(":matrix:services:sync") + implementation project(":matrix:services:message") + implementation project(":matrix:services:profile") + implementation project(":matrix:services:device") + implementation project(":matrix:services:room") + implementation project(":core") + implementation Dependencies.mavenCentral.kotlinSerializationJson + implementation Dependencies.mavenCentral.kotlinCoroutinesCore + implementation "com.squareup.sqldelight:coroutines-extensions:1.5.3" +} \ No newline at end of file diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/CredentialsPreferences.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/CredentialsPreferences.kt new file mode 100644 index 0000000..86cf790 --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/CredentialsPreferences.kt @@ -0,0 +1,24 @@ +package app.dapk.st.domain + +import app.dapk.st.matrix.common.UserCredentials +import app.dapk.st.matrix.common.CredentialsStore + +internal class CredentialsPreferences( + private val preferences: Preferences, +) : CredentialsStore { + + override suspend fun credentials(): UserCredentials? { + return preferences.readString("credentials")?.let { json -> + with(UserCredentials) { json.fromJson() } + } + } + + override suspend fun update(credentials: UserCredentials) { + val json = with(UserCredentials) { credentials.toJson() } + preferences.store("credentials", json) + } + + override suspend fun clear() { + preferences.clear() + } +} diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/DatabaseDropper.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/DatabaseDropper.kt new file mode 100644 index 0000000..1900f53 --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/DatabaseDropper.kt @@ -0,0 +1,5 @@ +package app.dapk.st.domain + +fun interface DatabaseDropper { + suspend fun dropAllTables(includeCryptoAccount: Boolean) +} \ No newline at end of file diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/DevicePersistence.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/DevicePersistence.kt new file mode 100644 index 0000000..123f9b7 --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/DevicePersistence.kt @@ -0,0 +1,99 @@ +package app.dapk.st.domain + +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.withIoContext +import app.dapk.db.DapkDb +import app.dapk.st.matrix.common.DeviceId +import app.dapk.st.matrix.common.SessionId +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.device.KnownDeviceStore +import app.dapk.st.matrix.device.internal.DeviceKeys +import kotlinx.serialization.json.Json + +class DevicePersistence( + private val database: DapkDb, + private val devicesCache: KnownDevicesCache, + private val dispatchers: CoroutineDispatchers, +) : KnownDeviceStore { + + override suspend fun associateSession(sessionId: SessionId, deviceIds: List) { + dispatchers.withIoContext { + database.deviceQueries.transaction { + deviceIds.forEach { + database.deviceQueries.insertDeviceToMegolmSession( + device_id = it.value, + session_id = sessionId.value + ) + } + } + } + } + + override suspend fun markOutdated(userIds: List) { + devicesCache.updateOutdated(userIds) + database.deviceQueries.markOutdated(userIds.map { it.value }) + } + + override suspend fun maybeConsumeOutdated(userIds: List): List { + return devicesCache.consumeOutdated(userIds).also { + database.deviceQueries.markIndate(userIds.map { it.value }) + } + } + + override suspend fun updateDevices(devices: Map>): List { + devicesCache.putAll(devices) + database.deviceQueries.transaction { + devices.forEach { (userId, innerMap) -> + innerMap.forEach { (deviceId, keys) -> + database.deviceQueries.insertDevice( + user_id = userId.value, + device_id = deviceId.value, + blob = Json.encodeToString(DeviceKeys.serializer(), keys), + ) + } + } + } + return devicesCache.devices() + } + + override suspend fun devicesMegolmSession(userIds: List, sessionId: SessionId): List { + return database.deviceQueries.selectUserDevicesWithSessions(userIds.map { it.value }, sessionId.value).executeAsList().map { + Json.decodeFromString(DeviceKeys.serializer(), it.blob) + } + } + + override suspend fun device(userId: UserId, deviceId: DeviceId): DeviceKeys? { + return devicesCache.device(userId, deviceId) ?: database.deviceQueries.selectDevice(deviceId.value).executeAsOneOrNull()?.let { + Json.decodeFromString(DeviceKeys.serializer(), it) + }?.also { devicesCache.putAll(mapOf(userId to mapOf(deviceId to it))) } + } +} + +class KnownDevicesCache( + private val devicesCache: Map> = mutableMapOf(), + private var outdatedUserIds: MutableSet = mutableSetOf() +) { + + fun consumeOutdated(userIds: List): List { + val outdatedToConsume = outdatedUserIds.filter { userIds.contains(it) } +// val unknownIds = userIds.filter { devicesCache[it] == null } + outdatedUserIds = (outdatedUserIds - outdatedToConsume.toSet()).toMutableSet() + return outdatedToConsume + } + + fun updateOutdated(userIds: List) { + outdatedUserIds.addAll(userIds) + } + + fun putAll(devices: Map>) { + devices.mapValues { it.value.toMutableMap() } + } + + fun devices(): List { + return devicesCache.values.map { it.values }.flatten() + } + + fun device(userId: UserId, deviceId: DeviceId): DeviceKeys? { + return devicesCache[userId]?.get(deviceId) + } +} \ No newline at end of file diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/FilterPreferences.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/FilterPreferences.kt new file mode 100644 index 0000000..24409a0 --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/FilterPreferences.kt @@ -0,0 +1,16 @@ +package app.dapk.st.domain + +import app.dapk.st.matrix.sync.FilterStore + +internal class FilterPreferences( + private val preferences: Preferences +) : FilterStore { + + override suspend fun store(key: String, filterId: String) { + preferences.store(key, filterId) + } + + override suspend fun read(key: String): String? { + return preferences.readString(key) + } +} \ No newline at end of file diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/MemberPersistence.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/MemberPersistence.kt new file mode 100644 index 0000000..101b39f --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/MemberPersistence.kt @@ -0,0 +1,38 @@ +package app.dapk.st.domain + +import app.dapk.db.DapkDb +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.withIoContext +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.room.MemberStore +import kotlinx.serialization.json.Json + +class MemberPersistence( + private val database: DapkDb, + private val coroutineDispatchers: CoroutineDispatchers, +) : MemberStore { + + override suspend fun insert(roomId: RoomId, members: List) { + coroutineDispatchers.withIoContext { + database.roomMemberQueries.transaction { + members.forEach { + database.roomMemberQueries.insert( + user_id = it.id.value, + room_id = roomId.value, + blob = Json.encodeToString(RoomMember.serializer(), it), + ) + } + } + } + } + + override suspend fun query(roomId: RoomId, userIds: List): List { + return coroutineDispatchers.withIoContext { + database.roomMemberQueries.selectMembersByRoomAndId(roomId.value, userIds.map { it.value }) + .executeAsList() + .map { Json.decodeFromString(RoomMember.serializer(), it) } + } + } +} \ No newline at end of file diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/OlmPersistence.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/OlmPersistence.kt new file mode 100644 index 0000000..80d0a26 --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/OlmPersistence.kt @@ -0,0 +1,85 @@ +package app.dapk.st.domain + +import app.dapk.db.DapkDb +import app.dapk.db.model.DbCryptoAccount +import app.dapk.db.model.DbCryptoMegolmInbound +import app.dapk.db.model.DbCryptoMegolmOutbound +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.common.Curve25519 +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.SessionId + +class OlmPersistence( + private val database: DapkDb, + private val credentialsStore: CredentialsStore, +) { + + suspend fun read(): String? { + return database.cryptoQueries + .selectAccount(credentialsStore.credentials()!!.userId.value) + .executeAsOneOrNull() + } + + suspend fun persist(olmAccount: SerializedObject) { + database.cryptoQueries.insertAccount( + DbCryptoAccount( + user_id = credentialsStore.credentials()!!.userId.value, + blob = olmAccount.value + ) + ) + } + + suspend fun readOutbound(roomId: RoomId): Pair? { + return database.cryptoQueries + .selectMegolmOutbound(roomId.value) + .executeAsOneOrNull()?.let { + it.utcEpochMillis to it.blob + } + } + + suspend fun persistOutbound(roomId: RoomId, creationTimestampUtc: Long, outboundGroupSession: SerializedObject) { + database.cryptoQueries.insertMegolmOutbound( + DbCryptoMegolmOutbound( + room_id = roomId.value, + blob = outboundGroupSession.value, + utcEpochMillis = creationTimestampUtc, + ) + ) + } + + suspend fun persistSession(identity: Curve25519, sessionId: SessionId, olmSession: SerializedObject) { + database.cryptoQueries.insertOlmSession( + identity_key = identity.value, + session_id = sessionId.value, + blob = olmSession.value, + ) + } + + suspend fun readSessions(identities: List): List>? { + return database.cryptoQueries + .selectOlmSession(identities.map { it.value }) + .executeAsList() + .map { Curve25519(it.identity_key) to it.blob } + .takeIf { it.isNotEmpty() } + } + + suspend fun persist(sessionId: SessionId, inboundGroupSession: SerializedObject) { + database.cryptoQueries.insertMegolmInbound( + DbCryptoMegolmInbound( + session_id = sessionId.value, + blob = inboundGroupSession.value + ) + ) + } + + suspend fun readInbound(sessionId: SessionId): SerializedObject? { + return database.cryptoQueries + .selectMegolmInbound(sessionId.value) + .executeAsOneOrNull() + ?.let { SerializedObject((it)) } + } + +} + +@JvmInline +value class SerializedObject(val value: String) \ No newline at end of file diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/Preferences.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/Preferences.kt new file mode 100644 index 0000000..73fcffb --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/Preferences.kt @@ -0,0 +1,9 @@ +package app.dapk.st.domain + +interface Preferences { + + suspend fun store(key: String, value: String) + suspend fun readString(key: String): String? + suspend fun clear() + suspend fun remove(key: String) +} diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/StoreCleaner.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/StoreCleaner.kt new file mode 100644 index 0000000..894ef8f --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/StoreCleaner.kt @@ -0,0 +1,5 @@ +package app.dapk.st.domain + +fun interface StoreCleaner { + suspend fun cleanCache(removeCredentials: Boolean) +} \ No newline at end of file diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt new file mode 100644 index 0000000..c478116 --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt @@ -0,0 +1,57 @@ +package app.dapk.st.domain + +import app.dapk.db.DapkDb +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.core.extensions.unsafeLazy +import app.dapk.st.domain.eventlog.EventLogPersistence +import app.dapk.st.domain.localecho.LocalEchoPersistence +import app.dapk.st.domain.profile.ProfilePersistence +import app.dapk.st.domain.sync.OverviewPersistence +import app.dapk.st.domain.sync.RoomPersistence +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.message.LocalEchoStore +import app.dapk.st.matrix.room.MemberStore +import app.dapk.st.matrix.room.ProfileStore +import app.dapk.st.matrix.sync.FilterStore +import app.dapk.st.matrix.sync.OverviewStore +import app.dapk.st.matrix.sync.RoomStore +import app.dapk.st.matrix.sync.SyncStore + +class StoreModule( + private val database: DapkDb, + private val databaseDropper: DatabaseDropper, + private val preferences: Preferences, + private val credentialPreferences: Preferences, + private val errorTracker: ErrorTracker, + private val coroutineDispatchers: CoroutineDispatchers, +) { + + fun overviewStore(): OverviewStore = OverviewPersistence(database, coroutineDispatchers) + fun roomStore(): RoomStore = RoomPersistence(database, OverviewPersistence(database, coroutineDispatchers), coroutineDispatchers) + fun credentialsStore(): CredentialsStore = CredentialsPreferences(credentialPreferences) + fun syncStore(): SyncStore = SyncTokenPreferences(preferences) + fun filterStore(): FilterStore = FilterPreferences(preferences) + val localEchoStore: LocalEchoStore by unsafeLazy { LocalEchoPersistence(errorTracker, database) } + + fun olmStore() = OlmPersistence(database, credentialsStore()) + fun knownDevicesStore() = DevicePersistence(database, KnownDevicesCache(), coroutineDispatchers) + + fun profileStore(): ProfileStore = ProfilePersistence(preferences) + + fun cacheCleaner() = StoreCleaner { cleanCredentials -> + if (cleanCredentials) { + credentialPreferences.clear() + } + preferences.clear() + databaseDropper.dropAllTables(includeCryptoAccount = cleanCredentials) + } + + fun eventLogStore(): EventLogPersistence { + return EventLogPersistence(database, coroutineDispatchers) + } + + fun memberStore(): MemberStore { + return MemberPersistence(database, coroutineDispatchers) + } +} diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/SyncTokenPreferences.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/SyncTokenPreferences.kt new file mode 100644 index 0000000..2be221e --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/SyncTokenPreferences.kt @@ -0,0 +1,24 @@ +package app.dapk.st.domain + +import app.dapk.st.matrix.common.SyncToken +import app.dapk.st.matrix.sync.SyncStore +import app.dapk.st.matrix.sync.SyncStore.SyncKey + +internal class SyncTokenPreferences( + private val preferences: Preferences +) : SyncStore { + + override suspend fun store(key: SyncKey, syncToken: SyncToken) { + preferences.store(key.value, syncToken.value) + } + + override suspend fun read(key: SyncKey): SyncToken? { + return preferences.readString(key.value)?.let { + SyncToken(it) + } + } + + override suspend fun remove(key: SyncKey) { + preferences.remove(key.value) + } +} diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/eventlog/EventLogPersistence.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/eventlog/EventLogPersistence.kt new file mode 100644 index 0000000..ff65dca --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/eventlog/EventLogPersistence.kt @@ -0,0 +1,62 @@ +package app.dapk.st.domain.eventlog + +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.withIoContext +import app.dapk.db.DapkDb +import com.squareup.sqldelight.runtime.coroutines.asFlow +import com.squareup.sqldelight.runtime.coroutines.mapToList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class EventLogPersistence( + private val database: DapkDb, + private val coroutineDispatchers: CoroutineDispatchers, +) { + + suspend fun insert(tag: String, content: String) { + coroutineDispatchers.withIoContext { + database.eventLoggerQueries.insert( + tag = tag, + content = content, + ) + } + } + + suspend fun days(): List { + return coroutineDispatchers.withIoContext { + database.eventLoggerQueries.selectDays().executeAsList() + } + } + + fun latest(logKey: String, filter: String?): Flow> { + return when (filter) { + null -> database.eventLoggerQueries.selectLatestByLog(logKey) + .asFlow() + .mapToList(context = coroutineDispatchers.io) + .map { + it.map { + LogLine( + tag = it.tag, + content = it.content, + time = it.time, + ) + } + } + else -> database.eventLoggerQueries.selectLatestByLogFiltered(logKey, filter) + .asFlow() + .mapToList(context = coroutineDispatchers.io) + .map { + it.map { + LogLine( + tag = it.tag, + content = it.content, + time = it.time, + ) + } + } + } + } + +} + +data class LogLine(val tag: String, val content: String, val time: String) diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/localecho/LocalEchoPersistence.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/localecho/LocalEchoPersistence.kt new file mode 100644 index 0000000..bb28e31 --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/localecho/LocalEchoPersistence.kt @@ -0,0 +1,109 @@ +package app.dapk.st.domain.localecho + +import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.core.extensions.Scope +import app.dapk.db.DapkDb +import app.dapk.db.model.DbLocalEcho +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.message.LocalEchoStore +import app.dapk.st.matrix.message.MessageService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json + +private typealias LocalEchoCache = Map> + +class LocalEchoPersistence( + private val errorTracker: ErrorTracker, + private val database: DapkDb, +) : LocalEchoStore { + + private val inMemoryEchos = MutableStateFlow(emptyMap()) + private val mirrorScope = Scope(newSingleThreadContext("local-echo-thread")) + + override suspend fun preload() { + withContext(Dispatchers.IO) { + val echos = database.localEchoQueries.selectAll().executeAsList().map { + Json.decodeFromString(MessageService.LocalEcho.serializer(), it.blob) + } + inMemoryEchos.value = echos.groupBy { + when (val message = it.message) { + is MessageService.Message.TextMessage -> message.roomId + } + }.mapValues { + it.value.associateBy { + when (val message = it.message) { + is MessageService.Message.TextMessage -> message.localId + } + } + } + } + } + + override fun markSending(message: MessageService.Message) { + emitUpdate(MessageService.LocalEcho(eventId = null, message, state = MessageService.LocalEcho.State.Sending)) + } + + override suspend fun messageTransaction(message: MessageService.Message, action: suspend () -> EventId) { + emitUpdate(MessageService.LocalEcho(eventId = null, message, state = MessageService.LocalEcho.State.Sending)) + try { + val eventId = action.invoke() + emitUpdate(MessageService.LocalEcho(eventId = eventId, message, state = MessageService.LocalEcho.State.Sent)) + database.transaction { + when (message) { + is MessageService.Message.TextMessage -> database.localEchoQueries.delete(message.localId) + } + } + } catch (error: Exception) { + emitUpdate( + MessageService.LocalEcho( + eventId = null, + message, + state = MessageService.LocalEcho.State.Error(error.message ?: "", MessageService.LocalEcho.State.Error.Type.UNKNOWN) + ) + ) + errorTracker.track(error) + throw error + } + } + + private fun emitUpdate(localEcho: MessageService.LocalEcho) { + val newValue = inMemoryEchos.value.addEcho(localEcho) + inMemoryEchos.tryEmit(newValue) + + mirrorScope.launch { + when (val message = localEcho.message) { + is MessageService.Message.TextMessage -> database.localEchoQueries.insert( + DbLocalEcho( + message.localId, + message.roomId.value, + Json.encodeToString(MessageService.LocalEcho.serializer(), localEcho) + ) + ) + } + } + } + + override fun observeLocalEchos(roomId: RoomId) = inMemoryEchos.map { + it[roomId]?.values?.toList() ?: emptyList() + } + + override fun observeLocalEchos() = inMemoryEchos.map { + it.mapValues { it.value.values.toList() } + } +} + +private fun LocalEchoCache.addEcho(localEcho: MessageService.LocalEcho): MutableMap> { + val newValue = this.toMutableMap() + val roomEchos = newValue.getOrPut(localEcho.roomId) { emptyMap() } + newValue[localEcho.roomId] = roomEchos.toMutableMap().also { it.update(localEcho) } + return newValue +} + +private fun MutableMap.update(localEcho: MessageService.LocalEcho) { + this[localEcho.localId] = localEcho +} diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/profile/ProfilePersistence.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/profile/ProfilePersistence.kt new file mode 100644 index 0000000..f133963 --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/profile/ProfilePersistence.kt @@ -0,0 +1,51 @@ +package app.dapk.st.domain.profile + +import app.dapk.st.domain.Preferences +import app.dapk.st.matrix.common.AvatarUrl +import app.dapk.st.matrix.common.HomeServerUrl +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.room.ProfileService +import app.dapk.st.matrix.room.ProfileStore +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +internal class ProfilePersistence( + private val preferences: Preferences, +) : ProfileStore { + + override suspend fun storeMe(me: ProfileService.Me) { + preferences.store( + "me", Json.encodeToString( + StoreMe.serializer(), StoreMe( + userId = me.userId, + displayName = me.displayName, + avatarUrl = me.avatarUrl, + homeServer = me.homeServerUrl, + ) + ) + ) + } + + override suspend fun readMe(): ProfileService.Me? { + return preferences.readString("me")?.let { + Json.decodeFromString(StoreMe.serializer(), it).let { + ProfileService.Me( + userId = it.userId, + displayName = it.displayName, + avatarUrl = it.avatarUrl, + homeServerUrl = it.homeServer + ) + } + } + } + +} + +@Serializable +private class StoreMe( + @SerialName("user_id") val userId: UserId, + @SerialName("display_name") val displayName: String?, + @SerialName("avatar_url") val avatarUrl: AvatarUrl?, + @SerialName("homeserver") val homeServer: HomeServerUrl, +) \ No newline at end of file diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/sync/OverviewPersistence.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/sync/OverviewPersistence.kt new file mode 100644 index 0000000..7c6a9c6 --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/sync/OverviewPersistence.kt @@ -0,0 +1,82 @@ +package app.dapk.st.domain.sync + +import app.dapk.db.DapkDb +import app.dapk.db.model.OverviewStateQueries +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.withIoContext +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.sync.OverviewState +import app.dapk.st.matrix.sync.OverviewStore +import app.dapk.st.matrix.sync.RoomInvite +import app.dapk.st.matrix.sync.RoomOverview +import com.squareup.sqldelight.runtime.coroutines.asFlow +import com.squareup.sqldelight.runtime.coroutines.mapToList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json + +private val json = Json + +internal class OverviewPersistence( + private val database: DapkDb, + private val dispatchers: CoroutineDispatchers, +) : OverviewStore { + + override fun latest(): Flow { + return database.overviewStateQueries.selectAll() + .asFlow() + .mapToList() + .map { it.map { json.decodeFromString(RoomOverview.serializer(), it.blob) } } + } + + override suspend fun persistInvites(invites: List) { + dispatchers.withIoContext { + database.inviteStateQueries.transaction { + invites.forEach { + database.inviteStateQueries.insert(it.roomId.value) + } + } + } + } + + override fun latestInvites(): Flow> { + return database.inviteStateQueries.selectAll() + .asFlow() + .mapToList() + .map { it.map { RoomInvite(RoomId(it)) } } + } + + override suspend fun persist(overviewState: OverviewState) { + dispatchers.withIoContext { + database.transaction { + overviewState.forEach { + database.overviewStateQueries.insertStateOverview(it) + } + } + } + } + + override suspend fun retrieve(): OverviewState { + return withContext(Dispatchers.IO) { + val overviews = database.overviewStateQueries.selectAll().executeAsList() + overviews.map { json.decodeFromString(RoomOverview.serializer(), it.blob) } + } + } + + internal fun retrieve(roomId: RoomId): RoomOverview? { + return database.overviewStateQueries.selectRoom(roomId.value).executeAsOneOrNull()?.let { + json.decodeFromString(RoomOverview.serializer(), it) + } + } +} + +private fun OverviewStateQueries.insertStateOverview(roomOverview: RoomOverview) { + this.insert( + room_id = roomOverview.roomId.value, + latest_activity_timestamp_utc = roomOverview.lastMessage?.utcTimestamp ?: roomOverview.roomCreationUtc, + blob = json.encodeToString(RoomOverview.serializer(), roomOverview), + read_marker = roomOverview.readMarker?.value + ) +} diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt new file mode 100644 index 0000000..0ed472b --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt @@ -0,0 +1,135 @@ +package app.dapk.st.domain.sync + +import app.dapk.db.DapkDb +import app.dapk.db.model.RoomEventQueries +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.withIoContext +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomOverview +import app.dapk.st.matrix.sync.RoomState +import app.dapk.st.matrix.sync.RoomStore +import com.squareup.sqldelight.runtime.coroutines.asFlow +import com.squareup.sqldelight.runtime.coroutines.mapToList +import com.squareup.sqldelight.runtime.coroutines.mapToOneNotNull +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json + +private val json = Json + +internal class RoomPersistence( + private val database: DapkDb, + private val overviewPersistence: OverviewPersistence, + private val coroutineDispatchers: CoroutineDispatchers, +) : RoomStore { + + override suspend fun persist(roomId: RoomId, state: RoomState) { + coroutineDispatchers.withIoContext { + database.transaction { + state.events.forEach { + database.roomEventQueries.insertRoomEvent(roomId, it) + } + } + } + } + + override fun latest(roomId: RoomId): Flow { + val overviewFlow = database.overviewStateQueries.selectRoom(roomId.value).asFlow().mapToOneNotNull().map { + json.decodeFromString(RoomOverview.serializer(), it) + } + + return database.roomEventQueries.selectRoom(roomId.value) + .asFlow() + .mapToList() + .map { it.map { json.decodeFromString(RoomEvent.serializer(), it) } } + .combine(overviewFlow) { events, overview -> + RoomState(overview, events) + } + } + + override suspend fun retrieve(roomId: RoomId): RoomState? { + return coroutineDispatchers.withIoContext { + overviewPersistence.retrieve(roomId)?.let { overview -> + val roomEvents = database.roomEventQueries.selectRoom(roomId.value).executeAsList().map { + json.decodeFromString(RoomEvent.serializer(), it) + } + RoomState(overview, roomEvents) + } + } + } + + override suspend fun insertUnread(roomId: RoomId, eventIds: List) { + coroutineDispatchers.withIoContext { + database.transaction { + eventIds.forEach { eventId -> + database.unreadEventQueries.insertUnread( + event_id = eventId.value, + room_id = roomId.value, + ) + } + } + } + } + + override suspend fun observeUnread(): Flow>> { + return database.roomEventQueries.selectAllUnread() + .asFlow() + .mapToList() + .distinctUntilChanged() + .map { + it.groupBy { RoomId(it.room_id) } + .mapKeys { overviewPersistence.retrieve(it.key)!! } + .mapValues { + it.value.map { + json.decodeFromString(RoomEvent.serializer(), it.blob) + } + } + } + } + + override suspend fun observeUnreadCountById(): Flow> { + return database.roomEventQueries.selectAllUnread() + .asFlow() + .mapToList() + .map { + it.groupBy { RoomId(it.room_id) } + .mapValues { it.value.size } + } + } + + override suspend fun markRead(roomId: RoomId) { + coroutineDispatchers.withIoContext { + database.unreadEventQueries.removeRead(room_id = roomId.value) + } + } + + override suspend fun observeEvent(eventId: EventId): Flow { + return database.roomEventQueries.selectEvent(event_id = eventId.value) + .asFlow() + .mapToOneNotNull() + .map { EventId(it) } + } + + override suspend fun findEvent(eventId: EventId): RoomEvent? { + return coroutineDispatchers.withIoContext { + database.roomEventQueries.selectEventContent(event_id = eventId.value) + .executeAsOneOrNull() + ?.let { json.decodeFromString(RoomEvent.serializer(), it) } + } + } +} + +private fun RoomEventQueries.insertRoomEvent(roomId: RoomId, roomEvent: RoomEvent) { + this.insert( + app.dapk.db.model.DbRoomEvent( + event_id = roomEvent.eventId.value, + room_id = roomId.value, + timestamp_utc = roomEvent.utcTimestamp, + blob = json.encodeToString(RoomEvent.serializer(), roomEvent), + ) + ) +} diff --git a/domains/store/src/main/sqldelight/app/dapk/db/model/Crypto.sq b/domains/store/src/main/sqldelight/app/dapk/db/model/Crypto.sq new file mode 100644 index 0000000..cba4742 --- /dev/null +++ b/domains/store/src/main/sqldelight/app/dapk/db/model/Crypto.sq @@ -0,0 +1,61 @@ +CREATE TABLE dbCryptoAccount ( + user_id TEXT NOT NULL, + blob TEXT NOT NULL, + PRIMARY KEY (user_id) +); + +CREATE TABLE dbCryptoOlmSession ( + identity_key TEXT NOT NULL, + session_id TEXT NOT NULL, + blob TEXT NOT NULL, + PRIMARY KEY (identity_key, session_id) +); + +CREATE TABLE dbCryptoMegolmInbound ( + session_id TEXT NOT NULL, + blob TEXT NOT NULL, + PRIMARY KEY (session_id) +); + +CREATE TABLE dbCryptoMegolmOutbound ( + room_id TEXT NOT NULL, + utcEpochMillis INTEGER NOT NULL, + blob TEXT NOT NULL, + PRIMARY KEY (room_id) +); + +selectAccount: +SELECT blob +FROM dbCryptoAccount +WHERE user_id = ?; + +insertAccount: +INSERT OR REPLACE INTO dbCryptoAccount(user_id, blob) +VALUES ?; + +selectOlmSession: +SELECT blob, identity_key +FROM dbCryptoOlmSession +WHERE identity_key IN ?; + +insertOlmSession: +INSERT OR REPLACE INTO dbCryptoOlmSession(identity_key, session_id, blob) +VALUES (?, ?, ?); + +selectMegolmInbound: +SELECT blob +FROM dbCryptoMegolmInbound +WHERE session_id = ?; + +insertMegolmInbound: +INSERT OR REPLACE INTO dbCryptoMegolmInbound(session_id, blob) +VALUES ?; + +selectMegolmOutbound: +SELECT blob, utcEpochMillis +FROM dbCryptoMegolmOutbound +WHERE room_id = ?; + +insertMegolmOutbound: +INSERT OR REPLACE INTO dbCryptoMegolmOutbound(room_id, utcEpochMillis, blob) +VALUES ?; \ No newline at end of file diff --git a/domains/store/src/main/sqldelight/app/dapk/db/model/Device.sq b/domains/store/src/main/sqldelight/app/dapk/db/model/Device.sq new file mode 100644 index 0000000..99156f8 --- /dev/null +++ b/domains/store/src/main/sqldelight/app/dapk/db/model/Device.sq @@ -0,0 +1,47 @@ +CREATE TABLE dbDeviceKey ( + user_id TEXT NOT NULL, + device_id TEXT NOT NULL, + blob TEXT NOT NULL, + outdated INTEGER AS Int NOT NULL, + PRIMARY KEY (user_id, device_id) +); + +CREATE TABLE dbDeviceKeyToMegolmSession ( + device_id TEXT NOT NULL, + session_id TEXT NOT NULL, + PRIMARY KEY (device_id, session_id) +); + +selectUserDevicesWithSessions: +SELECT user_id, dbDeviceKey.device_id, blob +FROM dbDeviceKey +JOIN dbDeviceKeyToMegolmSession ON dbDeviceKeyToMegolmSession.device_id = dbDeviceKey.device_id +WHERE user_id IN ? AND dbDeviceKeyToMegolmSession.session_id = ?; + +selectDevice: +SELECT blob +FROM dbDeviceKey +WHERE device_id = ?; + +selectOutdatedUsers: +SELECT user_id +FROM dbDeviceKey +WHERE outdated = 1; + +insertDevice: +INSERT OR REPLACE INTO dbDeviceKey(user_id, device_id, blob, outdated) +VALUES (?, ?, ?, 0); + +markOutdated: +UPDATE dbDeviceKey +SET outdated = 1 +WHERE user_id IN ?; + +markIndate: +UPDATE dbDeviceKey +SET outdated = 0 +WHERE user_id IN ?; + +insertDeviceToMegolmSession: +INSERT OR REPLACE INTO dbDeviceKeyToMegolmSession(device_id, session_id) +VALUES (?, ?); diff --git a/domains/store/src/main/sqldelight/app/dapk/db/model/EventLogger.sq b/domains/store/src/main/sqldelight/app/dapk/db/model/EventLogger.sq new file mode 100644 index 0000000..f900c14 --- /dev/null +++ b/domains/store/src/main/sqldelight/app/dapk/db/model/EventLogger.sq @@ -0,0 +1,28 @@ +CREATE TABLE dbEventLog ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + tag TEXT NOT NULL, + content TEXT NOT NULL, + utcEpochSeconds INTEGER NOT NULL, + logParent TEXT NOT NULL +); + +selectDays: +SELECT DISTINCT date(utcEpochSeconds,'unixepoch') +FROM dbEventLog; + +selectLatestByLog: +SELECT id, tag, content, time(utcEpochSeconds,'unixepoch') +FROM dbEventLog +WHERE logParent = ?; + +selectLatestByLogFiltered: +SELECT id, tag, content, time(utcEpochSeconds,'unixepoch') +FROM dbEventLog +WHERE logParent = ? AND tag = ?; + +insert: +INSERT INTO dbEventLog(tag, content, utcEpochSeconds, logParent) +VALUES (?, ?, strftime('%s','now'), strftime('%Y-%m-%d', 'now')); + + + diff --git a/domains/store/src/main/sqldelight/app/dapk/db/model/InviteState.sq b/domains/store/src/main/sqldelight/app/dapk/db/model/InviteState.sq new file mode 100644 index 0000000..d29ad00 --- /dev/null +++ b/domains/store/src/main/sqldelight/app/dapk/db/model/InviteState.sq @@ -0,0 +1,12 @@ +CREATE TABLE dbInviteState ( + room_id TEXT NOT NULL, + PRIMARY KEY (room_id) +); + +selectAll: +SELECT room_id +FROM dbInviteState; + +insert: +INSERT OR REPLACE INTO dbInviteState(room_id) +VALUES (?); \ No newline at end of file diff --git a/domains/store/src/main/sqldelight/app/dapk/db/model/LocalEcho.sq b/domains/store/src/main/sqldelight/app/dapk/db/model/LocalEcho.sq new file mode 100644 index 0000000..1ecf0e4 --- /dev/null +++ b/domains/store/src/main/sqldelight/app/dapk/db/model/LocalEcho.sq @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS dbLocalEcho ( + local_id TEXT NOT NULL, + room_id TEXT NOT NULL, + blob TEXT NOT NULL, + PRIMARY KEY (local_id) +); + +selectAll: +SELECT * +FROM dbLocalEcho; + +insert: +INSERT OR REPLACE INTO dbLocalEcho(local_id, room_id, blob) +VALUES ?; + +delete: +DELETE FROM dbLocalEcho +WHERE local_id = ?; diff --git a/domains/store/src/main/sqldelight/app/dapk/db/model/OverviewState.sq b/domains/store/src/main/sqldelight/app/dapk/db/model/OverviewState.sq new file mode 100644 index 0000000..afc9da1 --- /dev/null +++ b/domains/store/src/main/sqldelight/app/dapk/db/model/OverviewState.sq @@ -0,0 +1,21 @@ +CREATE TABLE dbOverviewState ( + room_id TEXT NOT NULL, + latest_activity_timestamp_utc INTEGER NOT NULL, + read_marker TEXT, + blob TEXT NOT NULL, + PRIMARY KEY (room_id) +); + +selectAll: +SELECT * +FROM dbOverviewState +ORDER BY latest_activity_timestamp_utc DESC; + +selectRoom: +SELECT blob +FROM dbOverviewState +WHERE room_id = ?; + +insert: +INSERT OR REPLACE INTO dbOverviewState(room_id, latest_activity_timestamp_utc, read_marker, blob) +VALUES (?, ?, ?, ?); \ No newline at end of file diff --git a/domains/store/src/main/sqldelight/app/dapk/db/model/RoomEvent.sq b/domains/store/src/main/sqldelight/app/dapk/db/model/RoomEvent.sq new file mode 100644 index 0000000..ce5a04b --- /dev/null +++ b/domains/store/src/main/sqldelight/app/dapk/db/model/RoomEvent.sq @@ -0,0 +1,33 @@ +CREATE TABLE IF NOT EXISTS dbRoomEvent ( + event_id TEXT NOT NULL, + room_id TEXT NOT NULL, + timestamp_utc INTEGER NOT NULL, + blob TEXT NOT NULL, + PRIMARY KEY (event_id) +); + +selectRoom: +SELECT blob +FROM dbRoomEvent +WHERE room_id = ? +ORDER BY timestamp_utc DESC +LIMIT 100; + +insert: +INSERT OR REPLACE INTO dbRoomEvent(event_id, room_id, timestamp_utc, blob) +VALUES ?; + +selectEvent: +SELECT event_id +FROM dbRoomEvent +WHERE event_id = ?; + +selectEventContent: +SELECT blob +FROM dbRoomEvent +WHERE event_id = ?; + +selectAllUnread: +SELECT dbRoomEvent.blob, dbRoomEvent.room_id +FROM dbUnreadEvent +INNER JOIN dbRoomEvent ON dbUnreadEvent.event_id = dbRoomEvent.event_id; diff --git a/domains/store/src/main/sqldelight/app/dapk/db/model/RoomMember.sq b/domains/store/src/main/sqldelight/app/dapk/db/model/RoomMember.sq new file mode 100644 index 0000000..eff8ab1 --- /dev/null +++ b/domains/store/src/main/sqldelight/app/dapk/db/model/RoomMember.sq @@ -0,0 +1,15 @@ +CREATE TABLE dbRoomMember ( + user_id TEXT NOT NULL, + room_id TEXT NOT NULL, + blob TEXT NOT NULL, + PRIMARY KEY (user_id, room_id) +); + +selectMembersByRoomAndId: +SELECT blob +FROM dbRoomMember +WHERE room_id = ? AND user_id IN ?; + +insert: +INSERT OR REPLACE INTO dbRoomMember(user_id, room_id, blob) +VALUES (?, ?, ?); \ No newline at end of file diff --git a/domains/store/src/main/sqldelight/app/dapk/db/model/UnreadEvent.sq b/domains/store/src/main/sqldelight/app/dapk/db/model/UnreadEvent.sq new file mode 100644 index 0000000..6ec0b58 --- /dev/null +++ b/domains/store/src/main/sqldelight/app/dapk/db/model/UnreadEvent.sq @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS dbUnreadEvent ( + event_id TEXT NOT NULL, + room_id TEXT NOT NULL, + PRIMARY KEY (event_id) +); + +insertUnread: +INSERT OR REPLACE INTO dbUnreadEvent(event_id, room_id) +VALUES (?, ?); + +removeRead: +DELETE FROM dbUnreadEvent +WHERE room_id = ?; + +selectUnreadByRoom: +SELECT event_id +FROM dbUnreadEvent +WHERE room_id = ?; diff --git a/external/jolm.jar b/external/jolm.jar new file mode 100644 index 0000000000000000000000000000000000000000..cd1c9ccef0b29884a2cd508f27e710b307ebe36f GIT binary patch literal 171501 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 + \ No newline at end of file diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryListingScreen.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryListingScreen.kt new file mode 100644 index 0000000..af0cf3a --- /dev/null +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryListingScreen.kt @@ -0,0 +1,258 @@ +package app.dapk.st.directory + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Button +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.dapk.st.core.LifecycleEffect +import app.dapk.st.core.StartObserving +import app.dapk.st.core.components.CenteredLoading +import app.dapk.st.directory.DirectoryEvent.OpenDownloadUrl +import app.dapk.st.directory.DirectoryScreenState.Content +import app.dapk.st.directory.DirectoryScreenState.EmptyLoading +import app.dapk.st.design.components.CircleishAvatar +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.sync.RoomOverview +import app.dapk.st.matrix.sync.SyncService +import app.dapk.st.messenger.MessengerActivity +import kotlinx.coroutines.launch +import java.time.Clock +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit + +@Composable +fun DirectoryScreen(directoryViewModel: DirectoryViewModel) { + val state = directoryViewModel.state + directoryViewModel.ObserveEvents() + LifecycleEffect( + onStart = { directoryViewModel.start() }, + onStop = { directoryViewModel.stop() } + ) + + when (state) { + is Content -> { + Content(state) + } + EmptyLoading -> CenteredLoading() + is Error -> { + Box(contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Something went wrong...") + Button(onClick = {}) { + Text("Retry") + } + } + } + } + } +} + +@Composable +private fun DirectoryViewModel.ObserveEvents() { + val context = LocalContext.current + StartObserving { + this@ObserveEvents.events.launch { + when (it) { + is OpenDownloadUrl -> { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it.url))) + } + } + } + } +} + +@Composable +private fun Content(state: Content) { + val context = LocalContext.current + val navigateToRoom = { roomId: RoomId -> + context.startActivity(MessengerActivity.newInstance(context, roomId)) + } + val clock = Clock.systemUTC() + val listState: LazyListState = rememberLazyListState( + initialFirstVisibleItemIndex = 0, + ) + val scope = rememberCoroutineScope() + + LaunchedEffect(key1 = state.overviewState) { + if (listState.firstVisibleItemScrollOffset <= 1) { + scope.launch { listState.scrollToItem(0) } + } + } + LazyColumn(Modifier.fillMaxSize(), state = listState) { + items( + items = state.overviewState, + key = { it.overview.roomId.value }, + ) { + DirectoryItem(it, onClick = navigateToRoom, clock) + } + } +} + +@Composable +private fun DirectoryItem(room: RoomFoo, onClick: (RoomId) -> Unit, clock: Clock) { + val overview = room.overview + val roomName = overview.roomName ?: "Empty room" + val hasUnread = room.unreadCount.value > 0 + + Box(Modifier.height(IntrinsicSize.Min).fillMaxWidth().clickable { + onClick(overview.roomId) + }) { + Row(Modifier.padding(20.dp)) { + val secondaryText = MaterialTheme.colors.onBackground.copy(alpha = 0.5f) + + Box(Modifier.fillMaxHeight(), contentAlignment = Alignment.Center) { + CircleishAvatar(overview.roomAvatarUrl?.value, roomName, size = 50.dp) + } + Spacer(Modifier.width(20.dp)) + Column { + Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + Text( + modifier = Modifier.weight(1f), + maxLines = 1, + fontSize = 18.sp, + text = roomName, + overflow = TextOverflow.Ellipsis, + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colors.onBackground + ) + Spacer(modifier = Modifier.width(6.dp)) + + val formattedTimestamp = remember(overview.lastMessage) { + overview.lastMessage?.utcTimestamp?.timeOrDate(clock.instant()) ?: overview.roomCreationUtc.timeOrDate(clock.instant()) + } + + Text( + modifier = Modifier.align(Alignment.CenterVertically), + fontSize = 12.sp, + maxLines = 1, + text = formattedTimestamp, + color = if (hasUnread) MaterialTheme.colors.primary else secondaryText + ) + } + + if (hasUnread) { + Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + Box(modifier = Modifier.weight(1f)) { + body(overview, secondaryText, room.typing) + } + Spacer(modifier = Modifier.width(6.dp)) + Box(Modifier.align(Alignment.CenterVertically)) { + Box( + Modifier.align(Alignment.Center).background(color = MaterialTheme.colors.primary, shape = CircleShape).size(22.dp), + contentAlignment = Alignment.Center + ) { + Text( + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + text = room.unreadCount.value.toString(), + color = MaterialTheme.colors.onPrimary + ) + } + } + } + } else { + body(overview, secondaryText, room.typing) + } + } + } + } +} + +@Composable +private fun body(overview: RoomOverview, secondaryText: Color, typing: SyncService.SyncEvent.Typing?) { + val bodySize = 14.sp + + when { + typing != null && typing.members.isNotEmpty() -> { + Text( + overflow = TextOverflow.Ellipsis, + fontSize = bodySize, + text = if (typing.members.size > 1) { + "People are typing..." + } else { + val member = typing.members.first() + val name = member.displayName ?: member.id.value + "$name is typing..." + }, + maxLines = 1, + color = MaterialTheme.colors.primary + ) + } + else -> when (val lastMessage = overview.lastMessage) { + null -> { + Text( + fontSize = bodySize, + text = "", + maxLines = 1, + color = secondaryText + ) + } + else -> { + when (overview.isGroup) { + true -> { + val displayName = lastMessage.author.displayName ?: lastMessage.author.id.value + Text( + overflow = TextOverflow.Ellipsis, + fontSize = bodySize, + text = "$displayName: ${lastMessage.content}", + maxLines = 1, + color = secondaryText + ) + } + false -> { + Text( + overflow = TextOverflow.Ellipsis, + fontSize = bodySize, + text = lastMessage.content, + maxLines = 1, + color = secondaryText + ) + } + } + } + } + } +} + +internal val DEFAULT_ZONE = ZoneId.systemDefault() +internal val OVERVIEW_TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm") +internal val OVERVIEW_DATE_FORMAT = DateTimeFormatter.ofPattern("dd/MM/yyyy") + +fun Long.timeOrDate(now: Instant): String { + return createTime(now) +} + +private fun Long.createTime(now: Instant): String { + val instant = Instant.ofEpochMilli(this) + val format = when { + instant.truncatedTo(ChronoUnit.DAYS) == now.truncatedTo(ChronoUnit.DAYS) -> OVERVIEW_TIME_FORMAT + else -> OVERVIEW_DATE_FORMAT + } + return ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalDateTime().format(format) +} \ No newline at end of file diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt new file mode 100644 index 0000000..5ef40d9 --- /dev/null +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt @@ -0,0 +1,32 @@ +package app.dapk.st.directory + +import android.content.Context +import app.dapk.st.core.ProvidableModule +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.room.RoomService +import app.dapk.st.matrix.sync.RoomStore +import app.dapk.st.matrix.sync.SyncService + +class DirectoryModule( + private val syncService: SyncService, + private val messageService: MessageService, + private val roomService: RoomService, + private val context: Context, + private val credentialsStore: CredentialsStore, + private val roomStore: RoomStore, +) : ProvidableModule { + + fun directoryViewModel(): DirectoryViewModel { + return DirectoryViewModel( + ShortcutHandler(context), + DirectoryUseCase( + syncService, + messageService, + roomService, + credentialsStore, + roomStore, + ) + ) + } +} \ No newline at end of file diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryState.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryState.kt new file mode 100644 index 0000000..1f7ebab --- /dev/null +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryState.kt @@ -0,0 +1,19 @@ +package app.dapk.st.directory + +sealed interface DirectoryScreenState { + + object EmptyLoading : DirectoryScreenState + data class Error(val cause: Throwable) : DirectoryScreenState + data class Content( + val overviewState: DirectoryState, +// val appState: AppState, +// val navigationState: NavigationState, +// val isRefreshing: Boolean = false, + ) : DirectoryScreenState +} + +sealed interface NavigationState {} + +sealed interface DirectoryEvent { data class OpenDownloadUrl(val url: String) : DirectoryEvent +} + diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryUseCase.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryUseCase.kt new file mode 100644 index 0000000..1d553ed --- /dev/null +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryUseCase.kt @@ -0,0 +1,87 @@ +package app.dapk.st.directory + +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.room.RoomService +import app.dapk.st.matrix.sync.* +import app.dapk.st.matrix.sync.SyncService.SyncEvent.Typing +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +@JvmInline +value class UnreadCount(val value: Int) + +typealias DirectoryState = List + +data class RoomFoo(val overview: RoomOverview, val unreadCount: UnreadCount, val typing: Typing?) + +class DirectoryUseCase( + private val syncService: SyncService, + private val messageService: MessageService, + private val roomService: RoomService, + private val credentialsStore: CredentialsStore, + private val roomStore: RoomStore, +) { + + suspend fun startSyncing(): Flow { + return syncService.startSyncing() + } + + suspend fun state(): Flow { + val userId = credentialsStore.credentials()!!.userId + return combine( + syncService.overview(), + messageService.localEchos(), + roomStore.observeUnreadCountById(), + syncService.events() + ) { overviewState, localEchos, unread, events -> + overviewState.mergeWithLocalEchos(localEchos, userId).map { roomOverview -> + RoomFoo( + overview = roomOverview, + unreadCount = UnreadCount(unread[roomOverview.roomId] ?: 0), + typing = events.filterIsInstance().firstOrNull { it.roomId == roomOverview.roomId } + ) + } + } + } + + private suspend fun OverviewState.mergeWithLocalEchos(localEchos: Map>, userId: UserId): OverviewState { + return when { + localEchos.isEmpty() -> this + else -> this.map { + when (val roomEchos = localEchos[it.roomId]) { + null -> it + else -> it.mergeWithLocalEchos( + member = roomService.findMember(it.roomId, userId) ?: RoomMember( + userId, + null, + avatarUrl = null, + ), + echos = roomEchos, + ) + } + } + } + } + + private fun RoomOverview.mergeWithLocalEchos(member: RoomMember, echos: List): RoomOverview { + val latestEcho = echos.maxByOrNull { it.timestampUtc } + return if (latestEcho != null && latestEcho.timestampUtc > (this.lastMessage?.utcTimestamp ?: 0)) { + this.copy( + lastMessage = LastMessage( + content = when (val message = latestEcho.message) { + is MessageService.Message.TextMessage -> message.content.body + }, + utcTimestamp = latestEcho.timestampUtc, + author = member, + ) + ) + } else { + this + } + } + +} diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryViewModel.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryViewModel.kt new file mode 100644 index 0000000..3351c58 --- /dev/null +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryViewModel.kt @@ -0,0 +1,40 @@ +package app.dapk.st.directory + +import androidx.lifecycle.viewModelScope +import app.dapk.st.core.DapkViewModel +import app.dapk.st.directory.DirectoryScreenState.Content +import app.dapk.st.directory.DirectoryScreenState.EmptyLoading +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class DirectoryViewModel( + private val shortcutHandler: ShortcutHandler, + private val directoryUseCase: DirectoryUseCase, +) : DapkViewModel( + initialState = EmptyLoading +) { + + private var syncJob: Job? = null + + fun start() { + syncJob = viewModelScope.launch { + directoryUseCase.startSyncing().collect() + } + + viewModelScope.launch { + directoryUseCase.state().onEach { + shortcutHandler.onDirectoryUpdate(it.map { it.overview }) + if (it.isNotEmpty()) { + state = Content(it) + } + }.collect() + } + } + + fun stop() { + syncJob?.cancel() + } +} + diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/ShortcutHandler.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/ShortcutHandler.kt new file mode 100644 index 0000000..b3f835b --- /dev/null +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/ShortcutHandler.kt @@ -0,0 +1,42 @@ +package app.dapk.st.directory + +import android.content.Context +import android.content.pm.ShortcutInfo +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.sync.RoomOverview +import app.dapk.st.messenger.MessengerActivity + +class ShortcutHandler(private val context: Context) { + + private val cachedRoomIds = mutableListOf() + + fun onDirectoryUpdate(overviews: List) { + + val update = overviews.map { it.roomId } + + if (cachedRoomIds != update) { + cachedRoomIds.clear() + cachedRoomIds.addAll(update) + + val currentShortcuts = ShortcutManagerCompat.getShortcuts(context, ShortcutManagerCompat.FLAG_MATCH_DYNAMIC) + val maxShortcutCountPerActivity = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context) + + overviews + .take(maxShortcutCountPerActivity) + .filterNot { roomUpdate -> currentShortcuts.any { it.id == roomUpdate.roomId.value } } + .forEachIndexed { index, room -> + val build = ShortcutInfoCompat.Builder(context, room.roomId.value) + .setShortLabel(room.roomName ?: "N/A") + .setRank(index) + .setIntent(MessengerActivity.newShortcutInstance(context, room.roomId)) + .setLongLived(true) + .setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION)) + .build() + ShortcutManagerCompat.pushDynamicShortcut(context, build) + } + } + } + +} \ No newline at end of file diff --git a/features/home/build.gradle b/features/home/build.gradle new file mode 100644 index 0000000..0d0aee0 --- /dev/null +++ b/features/home/build.gradle @@ -0,0 +1,14 @@ +applyAndroidLibraryModule(project) + +dependencies { + implementation project(":matrix:services:profile") + implementation project(":matrix:services:crypto") + implementation project(":features:directory") + implementation project(":features:login") + implementation project(":features:settings") + implementation project(":features:profile") + implementation project(":domains:android:core") + implementation project(':domains:store') + implementation project(":core") + implementation project(":design-library") +} \ No newline at end of file diff --git a/features/home/src/main/AndroidManifest.xml b/features/home/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3e51f2c --- /dev/null +++ b/features/home/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt new file mode 100644 index 0000000..12b1c35 --- /dev/null +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt @@ -0,0 +1,18 @@ +package app.dapk.st.home + +import app.dapk.st.core.ProvidableModule +import app.dapk.st.directory.DirectoryViewModel +import app.dapk.st.domain.StoreModule +import app.dapk.st.login.LoginViewModel +import app.dapk.st.matrix.room.ProfileService +import app.dapk.st.profile.ProfileViewModel + +class HomeModule( + private val storeModule: StoreModule, + private val profileService: ProfileService, +) : ProvidableModule { + + fun homeViewModel(directory: DirectoryViewModel, login: LoginViewModel, profileViewModel: ProfileViewModel): HomeViewModel { + return HomeViewModel(storeModule.credentialsStore(), directory, login, profileViewModel, profileService) + } +} \ No newline at end of file diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt new file mode 100644 index 0000000..ec85869 --- /dev/null +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt @@ -0,0 +1,84 @@ +package app.dapk.st.home + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import app.dapk.st.core.components.CenteredLoading +import app.dapk.st.design.components.CircleishAvatar +import app.dapk.st.design.components.SmallTalkTheme +import app.dapk.st.directory.DirectoryScreen +import app.dapk.st.home.HomeScreenState.* +import app.dapk.st.home.HomeScreenState.Page.Directory +import app.dapk.st.home.HomeScreenState.Page.Profile +import app.dapk.st.login.LoginScreen +import app.dapk.st.profile.ProfileScreen + +@Composable +fun HomeScreen(homeViewModel: HomeViewModel) { + SmallTalkTheme { + Surface(Modifier.fillMaxSize()) { + LaunchedEffect(true) { + homeViewModel.start() + } + + when (val state = homeViewModel.state) { + Loading -> CenteredLoading() + is SignedIn -> { + Scaffold( + bottomBar = { + BottomBar(state, homeViewModel) + }, + content = { innerPadding -> + Box(modifier = Modifier.padding(innerPadding)) { + when (state.page) { + Directory -> DirectoryScreen(homeViewModel.directory()) + Profile -> { + BackHandler { homeViewModel.changePage(Directory) } + ProfileScreen(homeViewModel.profile()) + } + } + } + } + ) + } + SignedOut -> { + LoginScreen(homeViewModel.login()) { + homeViewModel.loggedIn() + } + } + } + } + } +} + +@Composable +private fun BottomBar(state: SignedIn, homeViewModel: HomeViewModel) { + Column { + Divider(modifier = Modifier.fillMaxWidth(), color = Color.Black.copy(alpha = 0.2f), thickness = 0.5.dp) + BottomNavigation(elevation = 0.dp, backgroundColor = Color.Transparent, modifier = Modifier.height(IntrinsicSize.Min)) { + Page.values().forEach { page -> + when (page) { + Directory -> BottomNavigationItem( + icon = { Icon(page.icon, contentDescription = null) }, + selected = state.page == page, + onClick = { homeViewModel.changePage(page) }, + ) + Profile -> BottomNavigationItem( + icon = { + Box(modifier = Modifier.fillMaxHeight()) { + CircleishAvatar(state.me.avatarUrl?.value, state.me.displayName ?: state.me.userId.value, size = 25.dp) + } + }, + selected = state.page == page, + onClick = { homeViewModel.changePage(page) }, + ) + } + } + } + } +} diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeState.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeState.kt new file mode 100644 index 0000000..7012560 --- /dev/null +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeState.kt @@ -0,0 +1,23 @@ +package app.dapk.st.home + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Settings +import androidx.compose.ui.graphics.vector.ImageVector +import app.dapk.st.matrix.room.ProfileService + +sealed interface HomeScreenState { + + object Loading : HomeScreenState + object SignedOut : HomeScreenState + data class SignedIn(val page: Page, val me: ProfileService.Me) : HomeScreenState + + enum class Page(val icon: ImageVector) { + Directory(Icons.Filled.Menu), + Profile(Icons.Filled.Settings) + } + +} + +sealed interface HomeEvent + diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt new file mode 100644 index 0000000..1e0f9e9 --- /dev/null +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt @@ -0,0 +1,57 @@ +package app.dapk.st.home + +import androidx.lifecycle.viewModelScope +import app.dapk.st.core.DapkViewModel +import app.dapk.st.directory.DirectoryViewModel +import app.dapk.st.home.HomeScreenState.* +import app.dapk.st.login.LoginViewModel +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.common.isSignedIn +import app.dapk.st.matrix.room.ProfileService +import app.dapk.st.profile.ProfileViewModel +import kotlinx.coroutines.launch + +class HomeViewModel( + private val credentialsProvider: CredentialsStore, + private val directoryViewModel: DirectoryViewModel, + private val loginViewModel: LoginViewModel, + private val profileViewModel: ProfileViewModel, + private val profileService: ProfileService, +) : DapkViewModel( + initialState = Loading +) { + + fun directory() = directoryViewModel + fun login() = loginViewModel + fun profile() = profileViewModel + + fun start() { + viewModelScope.launch { + state = if (credentialsProvider.isSignedIn()) { + val me = profileService.me(forceRefresh = false) + SignedIn(Page.Directory, me) + } else { + SignedOut + } + } + } + + fun loggedIn() { + viewModelScope.launch { + val me = profileService.me(forceRefresh = false) + state = SignedIn(Page.Directory, me) + } + } + + fun loggedOut() { + state = SignedOut + } + + fun changePage(page: Page) { + state = when (val current = state) { + Loading -> current + is SignedIn -> current.copy(page = page) + SignedOut -> current + } + } +} diff --git a/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt new file mode 100644 index 0000000..aadb758 --- /dev/null +++ b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt @@ -0,0 +1,26 @@ +package app.dapk.st.home + +import android.os.Bundle +import androidx.activity.compose.setContent +import app.dapk.st.core.DapkActivity +import app.dapk.st.core.module +import app.dapk.st.core.viewModel +import app.dapk.st.directory.DirectoryModule +import app.dapk.st.login.LoginModule +import app.dapk.st.profile.ProfileModule + +class MainActivity : DapkActivity() { + + private val directoryViewModel by viewModel { module().directoryViewModel() } + private val loginViewModel by viewModel { module().loginViewModel() } + private val profileViewModel by viewModel { module().profileViewModel() } + private val homeViewModel by viewModel { module().homeViewModel(directoryViewModel, loginViewModel, profileViewModel) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + HomeScreen(homeViewModel) + } + } +} + diff --git a/features/login/build.gradle b/features/login/build.gradle new file mode 100644 index 0000000..5cf8c49 --- /dev/null +++ b/features/login/build.gradle @@ -0,0 +1,10 @@ +applyAndroidLibraryModule(project) + +dependencies { + implementation project(":domains:android:core") + implementation project(":domains:android:push") + implementation project(":matrix:services:auth") + implementation project(":matrix:services:profile") + implementation project(":matrix:services:crypto") + implementation project(":core") +} \ No newline at end of file diff --git a/features/login/src/main/AndroidManifest.xml b/features/login/src/main/AndroidManifest.xml new file mode 100644 index 0000000..403513e --- /dev/null +++ b/features/login/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt b/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt new file mode 100644 index 0000000..3b50c9b --- /dev/null +++ b/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt @@ -0,0 +1,20 @@ +package app.dapk.st.login + +import app.dapk.st.core.ProvidableModule +import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.matrix.auth.AuthService +import app.dapk.st.matrix.crypto.CryptoService +import app.dapk.st.matrix.room.ProfileService +import app.dapk.st.push.PushModule + +class LoginModule( + private val authService: AuthService, + private val pushModule: PushModule, + private val profileService: ProfileService, + private val errorTracker: ErrorTracker, +) : ProvidableModule { + + fun loginViewModel(): LoginViewModel { + return LoginViewModel(authService, pushModule.registerFirebasePushTokenUseCase(), profileService, errorTracker) + } +} \ No newline at end of file diff --git a/features/login/src/main/kotlin/app/dapk/st/login/LoginScreen.kt b/features/login/src/main/kotlin/app/dapk/st/login/LoginScreen.kt new file mode 100644 index 0000000..485c645 --- /dev/null +++ b/features/login/src/main/kotlin/app/dapk/st/login/LoginScreen.kt @@ -0,0 +1,141 @@ +package app.dapk.st.login + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.dapk.st.core.StartObserving +import app.dapk.st.login.LoginEvent.LoginComplete +import app.dapk.st.login.LoginScreenState.* + +@Composable +fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) { + loginViewModel.ObserveEvents(onLoggedIn) + LaunchedEffect(true) { + loginViewModel.start() + } + + var userName by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + + when (loginViewModel.state) { + is Error -> { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Something went wrong") + Spacer(modifier = Modifier.height(6.dp)) + Button(onClick = { + loginViewModel.start() + }) { + Text("Retry".uppercase()) + } + } + } + } + Loading -> { + Box(contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + Idle -> + Row { + Spacer(modifier = Modifier.weight(0.1f)) + Column( + modifier = Modifier + .weight(0.8f) + .fillMaxHeight() + ) { + Spacer(Modifier.fillMaxHeight(0.2f)) + + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text(text = "SmallTalk", fontSize = 34.sp) + } + + Spacer(Modifier.height(24.dp)) + + var passwordVisibility by rememberSaveable { mutableStateOf(false) } + + val focusManager = LocalFocusManager.current + TextField( + modifier = Modifier.fillMaxWidth(), + value = userName, + onValueChange = { userName = it }, + singleLine = true, + leadingIcon = { + Text(text = "@", fontWeight = FontWeight.ExtraBold, fontSize = 20.sp, modifier = Modifier.padding(bottom = 3.dp)) + }, + label = { Text("Username") }, + placeholder = { Text("hello:server.com") }, + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }), + keyboardOptions = KeyboardOptions(autoCorrect = false, keyboardType = KeyboardType.Email, imeAction = ImeAction.Next) + ) + + val canDoLoginAttempt = userName.isNotEmpty() && password.isNotEmpty() + + TextField( + modifier = Modifier.fillMaxWidth(), + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + singleLine = true, + leadingIcon = { + Icon(imageVector = Icons.Outlined.Lock, contentDescription = null) + }, + keyboardActions = KeyboardActions(onDone = { loginViewModel.login(userName, password) }), + keyboardOptions = KeyboardOptions( + autoCorrect = false, + imeAction = ImeAction.Done.takeIf { canDoLoginAttempt } ?: ImeAction.None, + keyboardType = KeyboardType.Password + ), + visualTransformation = if (passwordVisibility) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + val image = if (passwordVisibility) Icons.Filled.Visibility else Icons.Filled.VisibilityOff + IconButton(onClick = { passwordVisibility = !passwordVisibility }) { + Icon(imageVector = image, "") + } + } + ) + + Spacer(Modifier.height(4.dp)) + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { loginViewModel.login(userName, password) }, + enabled = canDoLoginAttempt + ) { + Text("Sign in".uppercase(), fontSize = 18.sp) + } + } + Spacer(modifier = Modifier.weight(0.1f)) + } + } +} + +@Composable +private fun LoginViewModel.ObserveEvents(onLoggedIn: () -> Unit) { + StartObserving { + this@ObserveEvents.events.launch { + when (it) { + LoginComplete -> onLoggedIn() + } + } + } +} + diff --git a/features/login/src/main/kotlin/app/dapk/st/login/LoginState.kt b/features/login/src/main/kotlin/app/dapk/st/login/LoginState.kt new file mode 100644 index 0000000..5e0245b --- /dev/null +++ b/features/login/src/main/kotlin/app/dapk/st/login/LoginState.kt @@ -0,0 +1,13 @@ +package app.dapk.st.login + +sealed interface LoginScreenState { + + object Idle : LoginScreenState + object Loading : LoginScreenState + data class Error(val cause: Throwable) : LoginScreenState +} + +sealed interface LoginEvent { + object LoginComplete : LoginEvent +} + diff --git a/features/login/src/main/kotlin/app/dapk/st/login/LoginViewModel.kt b/features/login/src/main/kotlin/app/dapk/st/login/LoginViewModel.kt new file mode 100644 index 0000000..f46eab5 --- /dev/null +++ b/features/login/src/main/kotlin/app/dapk/st/login/LoginViewModel.kt @@ -0,0 +1,52 @@ +package app.dapk.st.login + +import androidx.lifecycle.viewModelScope +import app.dapk.st.core.DapkViewModel +import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.core.logP +import app.dapk.st.login.LoginEvent.LoginComplete +import app.dapk.st.login.LoginScreenState.* +import app.dapk.st.matrix.auth.AuthService +import app.dapk.st.matrix.crypto.CryptoService +import app.dapk.st.matrix.room.ProfileService +import app.dapk.st.push.RegisterFirebasePushTokenUseCase +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch + +class LoginViewModel( + private val authService: AuthService, + private val firebasePushTokenUseCase: RegisterFirebasePushTokenUseCase, + private val profileService: ProfileService, + private val errorTracker: ErrorTracker, +) : DapkViewModel( + initialState = Idle +) { + + fun login(userName: String, password: String) { + state = Loading + viewModelScope.launch { + kotlin.runCatching { + logP("login") { + authService.login(userName, password).also { + listOf( + async { firebasePushTokenUseCase.registerCurrentToken() }, + async { preloadMe() }, + ).awaitAll() + } + } + }.onFailure { + errorTracker.track(it) + state = Error(it) + }.onSuccess { + _events.tryEmit(LoginComplete) + } + } + } + + private suspend fun preloadMe() = profileService.me(forceRefresh = false) + + fun start() { + state = Idle + } +} diff --git a/features/messenger/build.gradle b/features/messenger/build.gradle new file mode 100644 index 0000000..581f4eb --- /dev/null +++ b/features/messenger/build.gradle @@ -0,0 +1,13 @@ +applyAndroidLibraryModule(project) +apply plugin: 'kotlin-parcelize' + +dependencies { + implementation project(":matrix:services:sync") + implementation project(":matrix:services:message") + implementation project(":matrix:services:room") + implementation project(":domains:android:core") + implementation project(":core") + implementation project(":features:navigator") + implementation project(":design-library") + implementation("io.coil-kt:coil-compose:1.4.0") +} \ No newline at end of file diff --git a/features/messenger/src/main/AndroidManifest.xml b/features/messenger/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e643a26 --- /dev/null +++ b/features/messenger/src/main/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCase.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCase.kt new file mode 100644 index 0000000..65cba27 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCase.kt @@ -0,0 +1,65 @@ +package app.dapk.st.messenger + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.sync.MessageMeta +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomState + +internal typealias MergeWithLocalEchosUseCase = (RoomState, RoomMember, List) -> RoomState + +internal class MergeWithLocalEchosUseCaseImpl : MergeWithLocalEchosUseCase { + + override fun invoke(roomState: RoomState, member: RoomMember, echos: List): RoomState { + val echosByEventId = echos.associateBy { it.eventId } + val stateByEventId = roomState.events.associateBy { it.eventId } + + val uniqueEchos = echos.filter { echo -> + echo.eventId == null || stateByEventId[echo.eventId] == null + }.map { localEcho -> + when (val message = localEcho.message) { + is MessageService.Message.TextMessage -> { + createMessage(localEcho, message, member) + } + } + } + + val existingWithEcho = roomState.events.map { + when (val echo = echosByEventId[it.eventId]) { + null -> it + else -> when (it) { + is RoomEvent.Message -> it.copy( + meta = echo.toMeta() + ) + is RoomEvent.Reply -> it.copy(message = it.message.copy(meta = echo.toMeta())) + } + } + } + val sortedEvents = (existingWithEcho + uniqueEchos) + .sortedByDescending { if (it is RoomEvent.Message) it.utcTimestamp else null } + .distinctBy { it.eventId } + return roomState.copy(events = sortedEvents) + } +} + + +private fun createMessage(localEcho: MessageService.LocalEcho, message: MessageService.Message.TextMessage, member: RoomMember) = RoomEvent.Message( + eventId = localEcho.eventId ?: EventId(localEcho.localId), + content = message.content.body, + author = member, + utcTimestamp = message.timestampUtc, + meta = localEcho.toMeta() +) + +private fun MessageService.LocalEcho.toMeta() = MessageMeta.LocalEcho( + echoId = this.localId, + state = when (val localEchoState = this.state) { + MessageService.LocalEcho.State.Sending -> MessageMeta.LocalEcho.State.Sending + MessageService.LocalEcho.State.Sent -> MessageMeta.LocalEcho.State.Sent + is MessageService.LocalEcho.State.Error -> MessageMeta.LocalEcho.State.Error( + localEchoState.message, + type = MessageMeta.LocalEcho.State.Error.Type.UNKNOWN, + ) + } +) diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt new file mode 100644 index 0000000..4eb5dab --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt @@ -0,0 +1,57 @@ +package app.dapk.st.messenger + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.Parcelable +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Surface +import androidx.compose.ui.Modifier +import app.dapk.st.design.components.SmallTalkTheme +import app.dapk.st.core.DapkActivity +import app.dapk.st.core.module +import app.dapk.st.core.viewModel +import app.dapk.st.matrix.common.RoomId +import kotlinx.parcelize.Parcelize + +class MessengerActivity : DapkActivity() { + + private val viewModel by viewModel { module().messengerViewModel() } + + companion object { + + fun newInstance(context: Context, roomId: RoomId): Intent { + return Intent(context, MessengerActivity::class.java).apply { + putExtra("key", MessagerActivityPayload(roomId.value)) + } + } + + fun newShortcutInstance(context: Context, roomId: RoomId): Intent { + return Intent(context, MessengerActivity::class.java).apply { + action = "from_shortcut" + putExtra("shortcut_key", roomId.value) + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val payload = readPayload() + setContent { + SmallTalkTheme { + Surface(Modifier.fillMaxSize()) { + MessengerScreen(RoomId(payload.roomId), viewModel, navigator) + } + } + } + } +} + +@Parcelize +data class MessagerActivityPayload( + val roomId: String +) : Parcelable + +fun Activity.readPayload(): T = intent.getParcelableExtra("key")!! \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt new file mode 100644 index 0000000..45a48dc --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt @@ -0,0 +1,21 @@ +package app.dapk.st.messenger + +import app.dapk.st.core.ProvidableModule +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.room.RoomService +import app.dapk.st.matrix.sync.RoomStore +import app.dapk.st.matrix.sync.SyncService + +class MessengerModule( + private val syncService: SyncService, + private val messageService: MessageService, + private val roomService: RoomService, + private val credentialsStore: CredentialsStore, + private val roomStore: RoomStore, +) : ProvidableModule { + + fun messengerViewModel(): MessengerViewModel { + return MessengerViewModel(syncService, messageService, roomService, roomStore, credentialsStore) + } +} \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt new file mode 100644 index 0000000..cf1a3d2 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt @@ -0,0 +1,490 @@ +package app.dapk.st.messenger + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Send +import androidx.compose.runtime.* +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.SolidColor +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.dapk.st.core.Lce +import app.dapk.st.core.LifecycleEffect +import app.dapk.st.core.StartObserving +import app.dapk.st.core.components.CenteredLoading +import app.dapk.st.design.components.* +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.sync.MessageMeta +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomEvent.Message +import app.dapk.st.matrix.sync.RoomState +import app.dapk.st.navigator.Navigator +import kotlinx.coroutines.launch + +@Composable +fun MessengerScreen(roomId: RoomId, viewModel: MessengerViewModel, navigator: Navigator) { + val state = viewModel.state + + viewModel.ObserveEvents() + LifecycleEffect( + onStart = { viewModel.post(MessengerAction.OnMessengerVisible(roomId)) }, + onStop = { viewModel.post(MessengerAction.OnMessengerGone) } + ) + + val roomTitle = when (val roomState = state.roomState) { + is Lce.Content -> roomState.value.roomState.roomOverview.roomName + else -> null + } + + Column { + Toolbar(onNavigate = { navigator.navigate.upToHome() }, roomTitle, actions = { + OverflowMenu { + DropdownMenuItem(onClick = {}) { + Text("Settings") + } + } + }) + Room(state.roomState) + when (state.composerState) { + is ComposerState.Text -> { + Composer( + state.composerState.value, + onTextChange = { viewModel.post(MessengerAction.ComposerTextUpdate(it)) }, + onSend = { viewModel.post(MessengerAction.ComposerSendText) }, + ) + } + } + } +} + +@Composable +private fun MessengerViewModel.ObserveEvents() { + StartObserving { + this@ObserveEvents.events.launch { + when (it) { + } + } + } +} + +@Composable +private fun ColumnScope.Room(roomStateLce: Lce) { + when (val state = roomStateLce) { + is Lce.Loading -> CenteredLoading() + is Lce.Content -> { + RoomContent(state.value.self, state.value.roomState) + val eventBarHeight = 14.dp + val typing = state.value.typing + when { + typing == null || typing.members.isEmpty() -> Spacer(modifier = Modifier.height(eventBarHeight)) + else -> { + Box( + modifier = Modifier + .height(eventBarHeight) + .fillMaxWidth() + .padding(horizontal = 12.dp), contentAlignment = Alignment.CenterEnd + ) { + Text( + fontSize = 10.sp, + text = if (typing.members.size > 1) { + "People are typing..." + } else { + val member = typing.members.first() + val name = member.displayName ?: member.id.value + "$name is typing..." + }, + maxLines = 1, + color = MaterialTheme.colors.primary + ) + } + } + } + } + is Lce.Error -> { + Box(contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Something went wrong...") + Button(onClick = {}) { + Text("Retry") + } + } + } + } + } +} + +@Composable +private fun ColumnScope.RoomContent(self: UserId, state: RoomState) { + val listState: LazyListState = rememberLazyListState( + initialFirstVisibleItemIndex = 0 + ) + val scope = rememberCoroutineScope() + LaunchedEffect(key1 = state.events.size) { + if (listState.firstVisibleItemScrollOffset <= 1) { + scope.launch { listState.scrollToItem(0) } + } else { + // TODO show has new messages + } + } + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentPadding = PaddingValues(vertical = 8.dp), + state = listState, reverseLayout = true + ) { + itemsIndexed( + items = state.events, + key = { _, item -> item.eventId.value }, + ) { index, item -> + when (item) { + is Message -> { + val wasPreviousMessageSameSender = when (val previousEvent = if (index != 0) state.events[index - 1] else null) { + null -> false + is Message -> previousEvent.author.id == item.author.id + is RoomEvent.Reply -> previousEvent.message.author.id == item.author.id + } + Message(self, item, wasPreviousMessageSameSender) + } + is RoomEvent.Reply -> { + val wasPreviousMessageSameSender = when (val previousEvent = if (index != 0) state.events[index - 1] else null) { + null -> false + is Message -> previousEvent.author.id == item.message.author.id + is RoomEvent.Reply -> previousEvent.message.author.id == item.message.author.id + } + Reply(self, item, wasPreviousMessageSameSender) + } + } + } + } +} + +@Composable +private fun LazyItemScope.Message(self: UserId, message: Message, wasPreviousMessageSameSender: Boolean) { + when (message.author.id == self) { + true -> { + Box(modifier = Modifier.fillParentMaxWidth(), contentAlignment = Alignment.TopEnd) { + Box(modifier = Modifier.fillParentMaxWidth(0.85f), contentAlignment = Alignment.TopEnd) { + Bubble( + message = message, + isNotSelf = false, + wasPreviousMessageSameSender = wasPreviousMessageSameSender + ) { + TextBubbleContent(selfBackgroundShape, SmallTalkTheme.extendedColors.selfBubble, false, message) + } + } + } + } + false -> { + Box(modifier = Modifier.fillParentMaxWidth(0.95f), contentAlignment = Alignment.TopStart) { + Bubble( + message = message, + isNotSelf = true, + wasPreviousMessageSameSender = wasPreviousMessageSameSender + ) { + TextBubbleContent(othersBackgroundShape, SmallTalkTheme.extendedColors.othersBubble, true, message) + } + } + } + } +} + +@Composable +private fun LazyItemScope.Reply(self: UserId, message: RoomEvent.Reply, wasPreviousMessageSameSender: Boolean) { + when (message.message.author.id == self) { + true -> { + Box(modifier = Modifier.fillParentMaxWidth(), contentAlignment = Alignment.TopEnd) { + Box(modifier = Modifier.fillParentMaxWidth(0.85f), contentAlignment = Alignment.TopEnd) { + Bubble( + message = message.message, + isNotSelf = false, + wasPreviousMessageSameSender = wasPreviousMessageSameSender + ) { + ReplyBubbleContent(selfBackgroundShape, SmallTalkTheme.extendedColors.selfBubble, false, message) + } + } + } + } + false -> { + Box(modifier = Modifier.fillParentMaxWidth(0.95f), contentAlignment = Alignment.TopStart) { + Bubble( + message = message.message, + isNotSelf = true, + wasPreviousMessageSameSender = wasPreviousMessageSameSender + ) { + ReplyBubbleContent(othersBackgroundShape, SmallTalkTheme.extendedColors.othersBubble, true, message) + } + } + } + } +} + + +private val selfBackgroundShape = RoundedCornerShape(12.dp, 0.dp, 12.dp, 12.dp) +private val othersBackgroundShape = RoundedCornerShape(0.dp, 12.dp, 12.dp, 12.dp) + +@Composable +private fun Bubble( + message: Message, + isNotSelf: Boolean, + wasPreviousMessageSameSender: Boolean, + content: @Composable () -> Unit +) { + Row(Modifier.padding(horizontal = 12.dp)) { + when { + isNotSelf -> { + val displayImageSize = 32.dp + when { + wasPreviousMessageSameSender -> { + Spacer(modifier = Modifier.width(displayImageSize)) + } + message.author.avatarUrl == null -> { + MissingAvatarIcon(message.author.displayName ?: message.author.id.value, displayImageSize) + } + else -> { + MessengerUrlIcon(message.author.avatarUrl!!.value, displayImageSize) + } + } + } + } + content() + } +} + +@Composable +private fun TextBubbleContent(shape: RoundedCornerShape, background: Color, isNotSelf: Boolean, message: Message) { + Box(modifier = Modifier.padding(start = 6.dp)) { + Box( + Modifier + .padding(4.dp) + .clip(shape) + .background(background) + .height(IntrinsicSize.Max), + ) { + Column( + Modifier + .padding(8.dp) + .width(IntrinsicSize.Max) + .defaultMinSize(minWidth = 50.dp) + ) { + if (isNotSelf) { + Text( + fontSize = 11.sp, + text = message.author.displayName ?: message.author.id.value, + maxLines = 1, + color = MaterialTheme.colors.onPrimary + ) + } + Text( + text = message.content, + color = MaterialTheme.colors.onPrimary, + fontSize = 15.sp, + modifier = Modifier.wrapContentSize(), + textAlign = TextAlign.Start, + ) + + Spacer(modifier = Modifier.height(2.dp)) + Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + val editedPrefix = if (message.edited) "(edited) " else null + Text( + fontSize = 9.sp, + text = "${editedPrefix ?: ""}${message.time}", + textAlign = TextAlign.End, + color = MaterialTheme.colors.onPrimary, + modifier = Modifier.wrapContentSize() + ) + SendStatus(message) + } + } + } + } +} + +@Composable +private fun ReplyBubbleContent(shape: RoundedCornerShape, background: Color, isNotSelf: Boolean, reply: RoomEvent.Reply) { + Box(modifier = Modifier.padding(start = 6.dp)) { + Box( + Modifier + .padding(4.dp) + .clip(shape) + .background(background) + .height(IntrinsicSize.Max), + ) { + Column( + Modifier + .padding(8.dp) + .width(IntrinsicSize.Max) + .defaultMinSize(minWidth = 50.dp) + ) { + Column( + Modifier + .background(Color.Blue) + .padding(4.dp) + ) { + val replyName = if (!isNotSelf && reply.replyingToSelf) "You" else reply.message.author.displayName ?: reply.message.author.id.value + Text( + fontSize = 11.sp, + text = replyName, + maxLines = 1, + color = MaterialTheme.colors.onPrimary + ) + Text( + text = reply.replyingTo.content, + color = MaterialTheme.colors.onPrimary, + fontSize = 15.sp, + modifier = Modifier.wrapContentSize(), + textAlign = TextAlign.Start, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + if (isNotSelf) { + Text( + fontSize = 11.sp, + text = reply.message.author.displayName ?: reply.message.author.id.value, + maxLines = 1, + color = MaterialTheme.colors.onPrimary + ) + } + Text( + text = reply.message.content, + color = MaterialTheme.colors.onPrimary, + fontSize = 15.sp, + modifier = Modifier.wrapContentSize(), + textAlign = TextAlign.Start, + ) + + Spacer(modifier = Modifier.height(2.dp)) + Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + fontSize = 9.sp, + text = reply.time, + textAlign = TextAlign.End, + color = MaterialTheme.colors.onPrimary, + modifier = Modifier.wrapContentSize() + ) + SendStatus(reply.message) + } + } + } + } +} + + +@Composable +private fun RowScope.SendStatus(message: Message) { + when (val meta = message.meta) { + MessageMeta.FromServer -> { + // last message is self + } + is MessageMeta.LocalEcho -> { + when (val state = meta.state) { + MessageMeta.LocalEcho.State.Sending, MessageMeta.LocalEcho.State.Sent -> { + val isSent = state == MessageMeta.LocalEcho.State.Sent + Spacer(modifier = Modifier.width(4.dp)) + Box( + modifier = Modifier + .border(0.5.dp, MaterialTheme.colors.onPrimary, CircleShape) + .size(10.dp) + .padding(2.dp) + ) { + if (isSent) { + Icon(imageVector = Icons.Filled.Check, "", tint = MaterialTheme.colors.onPrimary) + } + } + } + is MessageMeta.LocalEcho.State.Error -> { + Spacer(modifier = Modifier.width(4.dp)) + Box( + modifier = Modifier + .border(0.5.dp, MaterialTheme.colors.error, CircleShape) + .size(10.dp) + .padding(2.dp) + ) { + Icon(imageVector = Icons.Filled.Close, "", tint = MaterialTheme.colors.error) + } + } + } + } + } +} + +@Composable +private fun Composer(message: String, onTextChange: (String) -> Unit, onSend: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + .fillMaxWidth() + .height(IntrinsicSize.Min), verticalAlignment = Alignment.Bottom + ) { + Box( + modifier = Modifier + .align(Alignment.Bottom) + .weight(1f) + .fillMaxHeight() + .background(MaterialTheme.colors.onSurface.copy(alpha = TextFieldDefaults.BackgroundOpacity), RoundedCornerShape(24.dp)), + contentAlignment = Alignment.TopStart, + ) { + Box(Modifier.padding(14.dp)) { + if (message.isEmpty()) { + Text("Message") + } + BasicTextField( + modifier = Modifier.fillMaxWidth(), + value = message, + onValueChange = { onTextChange(it) }, + cursorBrush = SolidColor(MaterialTheme.colors.primary), + textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current.copy(LocalContentAlpha.current)) + ) + } + } + Spacer(modifier = Modifier.width(6.dp)) + var size by remember { mutableStateOf(IntSize(0, 0)) } + IconButton( + enabled = message.isNotEmpty(), + modifier = Modifier + .clip(CircleShape) + .background(if (message.isEmpty()) Color.DarkGray else MaterialTheme.colors.primary) + .run { + if (size.height == 0 || size.width == 0) { + this + .onSizeChanged { + size = it + } + .fillMaxHeight() + } else { + with(LocalDensity.current) { + size(size.width.toDp(), size.height.toDp()) + } + } + }, + onClick = onSend, + ) { + Icon( + imageVector = Icons.Filled.Send, + contentDescription = "", + tint = MaterialTheme.colors.onPrimary, + ) + } + } +} diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt new file mode 100644 index 0000000..80e1aa1 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt @@ -0,0 +1,20 @@ +package app.dapk.st.messenger + +import app.dapk.st.core.Lce +import app.dapk.st.matrix.common.RoomId + +data class MessengerScreenState( + val roomId: RoomId?, + val roomState: Lce, + val composerState: ComposerState, +) + +sealed interface MessengerEvent + +sealed interface ComposerState { + + data class Text( + val value: String, + ) : ComposerState + +} diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt new file mode 100644 index 0000000..89cbcc8 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt @@ -0,0 +1,95 @@ +package app.dapk.st.messenger + +import androidx.lifecycle.viewModelScope +import app.dapk.st.core.DapkViewModel +import app.dapk.st.core.Lce +import app.dapk.st.core.extensions.takeIfContent +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.room.RoomService +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomStore +import app.dapk.st.matrix.sync.SyncService +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class MessengerViewModel( + syncService: SyncService, + private val messageService: MessageService, + private val roomService: RoomService, + private val roomStore: RoomStore, + private val credentialsStore: CredentialsStore, +) : DapkViewModel( + initialState = MessengerScreenState( + roomId = null, + roomState = Lce.Loading(), + composerState = ComposerState.Text(value = "") + ) +) { + + private var syncJob: Job? = null + private val useCase: TimelineUseCase = TimelineUseCase(syncService, messageService, roomService, MergeWithLocalEchosUseCaseImpl()) + + fun post(action: MessengerAction) { + when (action) { + is MessengerAction.ComposerTextUpdate -> { + updateState { copy(composerState = ComposerState.Text(action.newValue)) } + } + is MessengerAction.OnMessengerVisible -> { + updateState { copy(roomId = action.roomId) } + + syncJob = viewModelScope.launch { + useCase.startSyncing().collect() + } + viewModelScope.launch { + roomStore.markRead(action.roomId) + + val credentials = credentialsStore.credentials()!! + useCase.state(action.roomId, credentials.userId).distinctUntilChanged().onEach { state -> + state.roomState.events.filterIsInstance().filterNot { it.author.id == credentials.userId }.firstOrNull()?.let { + roomService.markFullyRead(state.roomState.roomOverview.roomId, it.eventId) + roomStore.markRead(state.roomState.roomOverview.roomId) + } + updateState { copy(roomState = Lce.Content(state)) } + }.collect() + } + } + MessengerAction.OnMessengerGone -> { + syncJob?.cancel() + } + MessengerAction.ComposerSendText -> { + when (val composerState = state.composerState) { + is ComposerState.Text -> { + val copy = composerState.copy() + updateState { copy(composerState = composerState.copy(value = "")) } + + state.roomState.takeIfContent()?.let { content -> + val roomState = content.roomState + viewModelScope.launch { + messageService.scheduleMessage( + MessageService.Message.TextMessage( + MessageService.Message.Content.TextContent(body = copy.value), + roomId = roomState.roomOverview.roomId, + sendEncrypted = roomState.roomOverview.isEncrypted, + ) + ) + } + } + } + } + } + } + } + +} + +sealed interface MessengerAction { + data class ComposerTextUpdate(val newValue: String) : MessengerAction + object ComposerSendText : MessengerAction + data class OnMessengerVisible(val roomId: RoomId) : MessengerAction + object OnMessengerGone : MessengerAction +} \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/TimelineUseCase.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/TimelineUseCase.kt new file mode 100644 index 0000000..854e2cb --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/TimelineUseCase.kt @@ -0,0 +1,51 @@ +package app.dapk.st.messenger + +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.room.RoomService +import app.dapk.st.matrix.sync.RoomState +import app.dapk.st.matrix.sync.SyncService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +data class MessengerState( + val self: UserId, + val roomState: RoomState, + val typing: SyncService.SyncEvent.Typing? +) + +internal class TimelineUseCase( + private val syncService: SyncService, + private val messageService: MessageService, + private val roomService: RoomService, + private val mergeWithLocalEchosUseCase: MergeWithLocalEchosUseCase +) { + + suspend fun startSyncing(): Flow { + return syncService.startSyncing() + } + + suspend fun state(roomId: RoomId, userId: UserId): Flow { + return combine(syncService.room(roomId), messageService.localEchos(roomId), syncService.events()) { roomState, localEchos, events -> + MessengerState( + roomState = when { + localEchos.isEmpty() -> roomState + else -> mergeWithLocalEchosUseCase.invoke( + roomState, + roomService.findMember(roomId, userId) ?: RoomMember( + userId, + null, + avatarUrl = null, + ), + localEchos, + ) + }, + typing = events.filterIsInstance().firstOrNull { it.roomId == roomId }, + self = userId, + ) + } + } + +} diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/roomsettings/RoomSettingsActivity.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/roomsettings/RoomSettingsActivity.kt new file mode 100644 index 0000000..79f8fe9 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/roomsettings/RoomSettingsActivity.kt @@ -0,0 +1,51 @@ +package app.dapk.st.messenger.roomsettings + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.Parcelable +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Surface +import androidx.compose.ui.Modifier +import app.dapk.st.messenger.MessengerModule +import app.dapk.st.messenger.MessengerScreen +import app.dapk.st.design.components.SmallTalkTheme +import app.dapk.st.core.DapkActivity +import app.dapk.st.core.module +import app.dapk.st.core.viewModel +import app.dapk.st.matrix.common.RoomId +import kotlinx.parcelize.Parcelize + +class RoomSettingsActivity : DapkActivity() { + + private val viewModel by viewModel { module().messengerViewModel() } + + companion object { + fun newInstance(context: Context, roomId: RoomId): Intent { + return Intent(context, RoomSettingsActivity::class.java).apply { + putExtra("key", RoomSettingsActivityPayload(roomId.value)) + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val payload = readPayload() + setContent { + SmallTalkTheme { + Surface(Modifier.fillMaxSize()) { + MessengerScreen(RoomId(payload.roomId), viewModel, navigator) + } + } + } + } +} + +@Parcelize +data class RoomSettingsActivityPayload( + val roomId: String +) : Parcelable + +fun Activity.readPayload(): T = intent.getParcelableExtra("key")!! \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/roomsettings/RoomSettingsScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/roomsettings/RoomSettingsScreen.kt new file mode 100644 index 0000000..9c3240f --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/roomsettings/RoomSettingsScreen.kt @@ -0,0 +1,17 @@ +package app.dapk.st.messenger.roomsettings + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import app.dapk.st.design.components.TextRow + +@Composable +fun RoomSettingsScreen() { + + + Column { + TextRow( + "Discard session", content = "" + ) + } + +} \ No newline at end of file diff --git a/features/navigator/build.gradle b/features/navigator/build.gradle new file mode 100644 index 0000000..c6b5dba --- /dev/null +++ b/features/navigator/build.gradle @@ -0,0 +1,6 @@ +applyAndroidLibraryModule(project) + +dependencies { + implementation project(":core") + implementation project(":matrix:common") +} \ No newline at end of file diff --git a/features/navigator/src/main/AndroidManifest.xml b/features/navigator/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9926105 --- /dev/null +++ b/features/navigator/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/features/navigator/src/main/kotlin/app/dapk/st/navigator/Navigator.kt b/features/navigator/src/main/kotlin/app/dapk/st/navigator/Navigator.kt new file mode 100644 index 0000000..28f7287 --- /dev/null +++ b/features/navigator/src/main/kotlin/app/dapk/st/navigator/Navigator.kt @@ -0,0 +1,63 @@ +package app.dapk.st.navigator + +import android.app.Activity +import android.content.Intent +import app.dapk.st.matrix.common.RoomId +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +interface Navigator { + + val navigate: Dsl + + class Dsl( + private val activity: Activity, + private val intentFactory: IntentFactory + ) { + + fun toHome(clearStack: Boolean = true) { + val home = intentFactory.home(activity).apply { + if (clearStack) { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + } + activity.startActivity(home) + } + + fun upToHome() { + activity.navigateUpTo(intentFactory.home(activity)) + } + + fun toFilePicker(requestCode: Int) { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + } + activity.startActivityForResult(intent, requestCode) + } + } +} + +interface IntentFactory { + + fun home(activity: Activity): Intent + fun messenger(activity: Activity, roomId: RoomId): Intent + fun messengerShortcut(activity: Activity, roomId: RoomId): Intent + + +} + +fun navigator(intentFactory: () -> IntentFactory): ReadOnlyProperty { + return NavigatorDelegate(intentFactory) +} + +private class NavigatorDelegate(private val intentFactory: () -> IntentFactory) : ReadOnlyProperty { + private var instanceCache: Navigator? = null + override fun getValue(thisRef: Activity, property: KProperty<*>): Navigator { + return instanceCache ?: DefaultNavigator(thisRef, intentFactory.invoke()) + } +} + +private class DefaultNavigator(activity: Activity, intentFactory: IntentFactory) : Navigator { + override val navigate: Navigator.Dsl = Navigator.Dsl(activity, intentFactory) +} + diff --git a/features/notifications/build.gradle b/features/notifications/build.gradle new file mode 100644 index 0000000..6ccaa59 --- /dev/null +++ b/features/notifications/build.gradle @@ -0,0 +1,15 @@ +applyAndroidLibraryModule(project) + +dependencies { + implementation project(":matrix:services:push") + implementation project(":matrix:services:sync") + implementation project(':domains:store') + implementation project(':domains:android:push') + implementation project(":domains:android:core") + implementation project(":core") + implementation project(":domains:android:imageloader") + implementation project(":features:messenger") + + implementation platform('com.google.firebase:firebase-bom:29.0.3') + implementation 'com.google.firebase:firebase-messaging' +} \ No newline at end of file diff --git a/features/notifications/src/main/AndroidManifest.xml b/features/notifications/src/main/AndroidManifest.xml new file mode 100644 index 0000000..11acb21 --- /dev/null +++ b/features/notifications/src/main/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationDisplayer.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationDisplayer.kt new file mode 100644 index 0000000..d7e47a9 --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationDisplayer.kt @@ -0,0 +1,30 @@ +package app.dapk.st.notifications + +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import app.dapk.st.core.extensions.ErrorTracker + +class NotificationDisplayer( + context: Context, + private val errorTracker: ErrorTracker, +) { + + private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + fun showNotificationMessage(tag: String?, id: Int, notification: Notification) { + notificationManager.notify(tag, id, notification) + } + + fun cancelNotificationMessage(tag: String?, id: Int) { + notificationManager.cancel(tag, id) + } + + fun cancelAllNotifications() { + try { + notificationManager.cancelAll() + } catch (e: Exception) { + errorTracker.track(e, "Failed to cancel all notifications") + } + } +} \ No newline at end of file diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt new file mode 100644 index 0000000..d71de40 --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt @@ -0,0 +1,35 @@ +package app.dapk.st.notifications + +import android.app.NotificationManager +import android.content.Context +import app.dapk.st.core.ProvidableModule +import app.dapk.st.imageloader.IconLoader +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.push.PushService +import app.dapk.st.matrix.sync.RoomStore +import app.dapk.st.matrix.sync.SyncService +import app.dapk.st.push.RegisterFirebasePushTokenUseCase + +class NotificationsModule( + private val pushService: PushService, + private val syncService: SyncService, + private val credentialsStore: CredentialsStore, + private val firebasePushTokenUseCase: RegisterFirebasePushTokenUseCase, + private val iconLoader: IconLoader, + private val roomStore: RoomStore, + private val context: Context, +) : ProvidableModule { + + fun pushUseCase() = pushService + fun syncService() = syncService + fun credentialProvider() = credentialsStore + fun roomStore() = roomStore + fun iconLoader() = iconLoader + fun firebasePushTokenUseCase() = firebasePushTokenUseCase + fun notificationsUseCase() = NotificationsUseCase( + roomStore, + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager, + iconLoader, + context, + ) +} diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsUseCase.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsUseCase.kt new file mode 100644 index 0000000..79cd2a3 --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsUseCase.kt @@ -0,0 +1,176 @@ +package app.dapk.st.notifications + +import android.R +import android.app.* +import android.app.Notification.InboxStyle +import android.content.Context +import app.dapk.st.core.AppLogTag.NOTIFICATION +import app.dapk.st.core.log +import app.dapk.st.imageloader.IconLoader +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomOverview +import app.dapk.st.matrix.sync.RoomStore +import app.dapk.st.messenger.MessengerActivity +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.onEach + +private const val SUMMARY_NOTIFICATION_ID = 101 +private const val MESSAGE_NOTIFICATION_ID = 100 +private const val GROUP_ID = "st" + +class NotificationsUseCase( + private val roomStore: RoomStore, + private val notificationManager: NotificationManager, + private val iconLoader: IconLoader, + private val context: Context, +) { + + private val inferredCurrentNotifications = mutableSetOf() + private val channelId = "message" + + init { + if (notificationManager.getNotificationChannel(channelId) == null) { + notificationManager.createNotificationChannel( + NotificationChannel( + channelId, + "messages", + NotificationManager.IMPORTANCE_HIGH, + ) + ) + } + } + + suspend fun listenForNotificationChanges() { + // TODO handle redactions by removing and edits by not notifying + + roomStore.observeUnread() + .drop(1) + .onEach { result -> + log(NOTIFICATION, "unread changed - render notifications") + + val asRooms = result.keys.map { it.roomId }.toSet() + val removedRooms = inferredCurrentNotifications - asRooms + removedRooms.forEach { notificationManager.cancel(it.value, MESSAGE_NOTIFICATION_ID) } + + inferredCurrentNotifications.clear() + inferredCurrentNotifications.addAll(asRooms) + + val notifications = result.map { (roomOverview, events) -> + val messageEvents = events.filterIsInstance() + when (messageEvents.isEmpty()) { + true -> NotificationDelegate.DismissRoom(roomOverview.roomId) + false -> createNotification(messageEvents, roomOverview) + } + } + + + val summaryNotification = if (notifications.filterIsInstance().size > 1) { + createSummary(notifications) + } else { + null + } + + if (summaryNotification == null) { + notificationManager.cancel(101) + } + + notifications.forEach { + when (it) { + is NotificationDelegate.DismissRoom -> notificationManager.cancel(it.roomId.value, MESSAGE_NOTIFICATION_ID) + is NotificationDelegate.Room -> notificationManager.notify(it.roomId.value, MESSAGE_NOTIFICATION_ID, it.notification) + } + } + + if (summaryNotification != null) { + notificationManager.notify(SUMMARY_NOTIFICATION_ID, summaryNotification) + } + + } + .collect() + } + + private fun createSummary(notifications: List): Notification { + val summaryInboxStyle = InboxStyle().also { style -> + notifications.forEach { + when (it) { + is NotificationDelegate.DismissRoom -> { + // do nothing + } + is NotificationDelegate.Room -> style.addLine(it.summary) + } + } + } + + return Notification.Builder(context, channelId) + .setStyle(summaryInboxStyle) + .setSmallIcon(R.drawable.ic_menu_send) + .setCategory(Notification.CATEGORY_MESSAGE) + .setGroupSummary(true) + .setGroup(GROUP_ID) + .build() + } + + private suspend fun createNotification(events: List, roomOverview: RoomOverview): NotificationDelegate { + val messageStyle = Notification.MessagingStyle( + Person.Builder() + .setName("me") + .setKey(roomOverview.roomId.value) + .build() + ) + + messageStyle.conversationTitle = roomOverview.roomName.takeIf { roomOverview.isGroup } + messageStyle.isGroupConversation = roomOverview.isGroup + + events.sortedBy { it.utcTimestamp }.forEach { message -> + val sender = Person.Builder() + .setName(message.author.displayName ?: message.author.id.value) + .setIcon(message.author.avatarUrl?.let { iconLoader.load(it.value) }) + .setKey(message.author.id.value) + .build() + messageStyle.addMessage( + Notification.MessagingStyle.Message( + message.content, + message.utcTimestamp, + sender, + ) + ) + } + + val openRoomIntent = PendingIntent.getActivity( + context, + 55, + MessengerActivity.newInstance(context, roomOverview.roomId), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationDelegate.Room( + Notification.Builder(context, channelId) + .setWhen(messageStyle.messages.last().timestamp) + .setShowWhen(true) + .setGroup(GROUP_ID) + .setOnlyAlertOnce(roomOverview.isGroup) + .setContentIntent(openRoomIntent) + .setStyle(messageStyle) + .setCategory(Notification.CATEGORY_MESSAGE) + .setShortcutId(roomOverview.roomId.value) + .setSmallIcon(R.drawable.ic_menu_send) + .setLargeIcon(roomOverview.roomAvatarUrl?.let { iconLoader.load(it.value) }) + .setAutoCancel(true) + .build(), + roomId = roomOverview.roomId, + summary = messageStyle.messages.last().text.toString() + ) + + } + +} + +sealed interface NotificationDelegate { + + data class Room(val notification: Notification, val roomId: RoomId, val summary: String) : NotificationDelegate + data class DismissRoom(val roomId: RoomId) : NotificationDelegate + + +} \ No newline at end of file diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/PushAndroidService.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/PushAndroidService.kt new file mode 100644 index 0000000..20bbeb4 --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/PushAndroidService.kt @@ -0,0 +1,81 @@ +package app.dapk.st.notifications + +import android.content.Context +import app.dapk.st.core.AppLogTag.PUSH +import app.dapk.st.core.extensions.unsafeLazy +import app.dapk.st.core.log +import app.dapk.st.core.module +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge + +private var previousJob: Job? = null + +@OptIn(DelicateCoroutinesApi::class) +class PushAndroidService : FirebaseMessagingService() { + + private val module by unsafeLazy { module() } + private lateinit var context: Context + + override fun onCreate() { + super.onCreate() + context = applicationContext + } + + override fun onNewToken(token: String) { + GlobalScope.launch { + module.pushUseCase().registerPush(token) + } + } + + override fun onMessageReceived(message: RemoteMessage) { + val eventId = message.data["event_id"]?.let { EventId(it) } + val roomId = message.data["room_id"]?.let { RoomId(it) } + + log(PUSH, "push received") + previousJob?.cancel() + previousJob = GlobalScope.launch { + when (module.credentialProvider().credentials()) { + null -> log(PUSH, "push ignored due to missing api credentials") + else -> doSync(roomId, eventId) + } + } + } + + private suspend fun doSync(roomId: RoomId?, eventId: EventId?) { + when (roomId) { + null -> { + log(PUSH, "empty push payload - triggering a sync if not running") + withTimeoutOrNull(60_000) { + log(PUSH, "got empty event, forcing a sync") + module.syncService().startSyncing().first() + } ?: log(PUSH, "timed out waiting for sync") + } + else -> { + log(PUSH, "push with eventId payload - keeping sync alive until the event shows up in the sync response") + waitForEvent( + timeout = 60_000, + eventId!!, + ) ?: log(PUSH, "timed out waiting for sync") + } + } + log(PUSH, "push sync finished") + } + + private suspend fun waitForEvent(timeout: Long, eventId: EventId): EventId? { + return withTimeoutOrNull(timeout) { + val syncFlow = module.syncService().startSyncing().map { it as EventId } + merge(syncFlow, module.syncService().observeEvent(eventId)) + .firstOrNull { + it == eventId + } + } + } + +} diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/RoomGroupMessageCreator.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/RoomGroupMessageCreator.kt new file mode 100644 index 0000000..d7a7d8c --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/RoomGroupMessageCreator.kt @@ -0,0 +1,124 @@ +package app.dapk.st.notifications + +import app.dapk.st.imageloader.IconLoader + +class RoomGroupMessageCreator( + private val iconLoader: IconLoader, +// private val bitmapLoader: BitmapLoader, +// private val stringProvider: StringProvider, +// private val notificationUtils: NotificationUtils, +// private val appContext: Context +) { + +// fun createRoomMessage(events: List, roomId: String, userDisplayName: String, userAvatarUrl: String?): RoomNotification.Message { +// val firstKnownRoomEvent = events[0] +// val roomName = firstKnownRoomEvent.roomName ?: firstKnownRoomEvent.senderName ?: "" +// val roomIsGroup = !firstKnownRoomEvent.roomIsDirect +// +// val style = Notification.MessagingStyle( +// Person.Builder() +// .setName(userDisplayName) +// .setIcon(iconLoader.load(userAvatarUrl)) +// .setKey(firstKnownRoomEvent.matrixID) +// .build() +// ).also { +// it.conversationTitle = roomName.takeIf { roomIsGroup } +// it.isGroupConversation = roomIsGroup +// it.addMessagesFromEvents(events) +// } +// +// val tickerText = if (roomIsGroup) { +// stringProvider.getString(R.string.notification_ticker_text_group, roomName, events.last().senderName, events.last().description) +// } else { +// stringProvider.getString(R.string.notification_ticker_text_dm, events.last().senderName, events.last().description) +// } +// +// val largeBitmap = getRoomBitmap(events) +// +// val lastMessageTimestamp = events.last().timestamp +// val smartReplyErrors = events.filter { it.isSmartReplyError() } +// val messageCount = (events.size - smartReplyErrors.size) +// val meta = RoomNotification.Message.Meta( +// summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, roomIsDirect = !roomIsGroup), +// messageCount = messageCount, +// latestTimestamp = lastMessageTimestamp, +// roomId = roomId, +// shouldBing = events.any { it.noisy } +// ) +// return RoomNotification.Message( +// notificationUtils.buildMessagesListNotification( +// style, +// RoomEventGroupInfo(roomId, roomName, isDirect = !roomIsGroup), +// largeIcon = largeBitmap, +// lastMessageTimestamp, +// userDisplayName, +// tickerText +// ), +// meta +// ) +// } + +// private fun Notification.MessagingStyle.addMessagesFromEvents(events: List) { +// events.forEach { event -> +// val senderPerson = if (event.outGoingMessage) { +// null +// } else { +// Person.Builder() +// .setName(event.senderName) +// .setIcon(iconLoader.getUserIcon(event.senderAvatarPath)) +// .setKey(event.senderId) +// .build() +// } +// when { +// event.isSmartReplyError() -> addMessage(stringProvider.getString(R.string.notification_inline_reply_failed), event.timestamp, senderPerson) +// else -> { +// val message = Notification.MessagingStyle.Message(event.body, event.timestamp, senderPerson).also { message -> +// event.imageUri?.let { +// message.setData("image/", it) +// } +// } +// addMessage(message) +// } +// } +// } +// } +// +// private fun createRoomMessagesGroupSummaryLine(events: List, roomName: String, roomIsDirect: Boolean): CharSequence { +// return when (events.size) { +// 1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDirect) +// else -> { +// stringProvider.getQuantityString( +// R.plurals.notification_compat_summary_line_for_room, +// events.size, +// roomName, +// events.size +// ) +// } +// } +// } +// +// private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): Span { +// return if (roomIsDirect) { +// buildSpannedString { +// bold { append("${event.senderName}: ") } +// append(event.description) +// } +// } else { +// buildSpannedString { +// bold { append("$roomName: ${event.senderName} ") } +// append(event.description) +// } +// } +// } +// +// private fun getRoomBitmap(events: List): Bitmap? { +// // Use the last event (most recent?) +// return events.lastOrNull() +// ?.roomAvatarPath +// ?.let { bitmapLoader.getRoomBitmap(it) } +// } +} + +//data class RoomMessage( +// +//) \ No newline at end of file diff --git a/features/profile/build.gradle b/features/profile/build.gradle new file mode 100644 index 0000000..78393a9 --- /dev/null +++ b/features/profile/build.gradle @@ -0,0 +1,11 @@ +applyAndroidLibraryModule(project) + +dependencies { + implementation project(":matrix:services:sync") + implementation project(":matrix:services:profile") + implementation project(":features:settings") + implementation project(':domains:store') + implementation project(":domains:android:core") + implementation project(":design-library") + implementation project(":core") +} \ No newline at end of file diff --git a/features/profile/src/main/AndroidManifest.xml b/features/profile/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8f0f1fd --- /dev/null +++ b/features/profile/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt new file mode 100644 index 0000000..eb7d780 --- /dev/null +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt @@ -0,0 +1,14 @@ +package app.dapk.st.profile + +import app.dapk.st.core.ProvidableModule +import app.dapk.st.matrix.room.ProfileService + +class ProfileModule( + private val profileService: ProfileService, +) : ProvidableModule { + + fun profileViewModel(): ProfileViewModel { + return ProfileViewModel(profileService) + } + +} \ No newline at end of file diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileScreen.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileScreen.kt new file mode 100644 index 0000000..7ee83f5 --- /dev/null +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileScreen.kt @@ -0,0 +1,102 @@ +package app.dapk.st.profile + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.Settings +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import app.dapk.st.core.LifecycleEffect +import app.dapk.st.core.StartObserving +import app.dapk.st.core.components.CenteredLoading +import app.dapk.st.design.components.CircleishAvatar +import app.dapk.st.design.components.TextRow +import app.dapk.st.design.components.percentOfHeight +import app.dapk.st.settings.SettingsActivity + +@Composable +fun ProfileScreen(viewModel: ProfileViewModel) { + viewModel.ObserveEvents() + + LifecycleEffect(onStart = { + viewModel.start() + }) + + val context = LocalContext.current + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), contentAlignment = Alignment.TopEnd + ) { + IconButton(onClick = { context.startActivity(Intent(context, SettingsActivity::class.java)) }) { + Icon(imageVector = Icons.Filled.Settings, contentDescription = "Settings") + } + } + + when (val state = viewModel.state) { + ProfileScreenState.Loading -> CenteredLoading() + is ProfileScreenState.Content -> { + val configuration = LocalConfiguration.current + + Column { + Spacer(modifier = Modifier.fillMaxHeight(0.05f)) + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + val fallbackLabel = state.me.displayName ?: state.me.userId.value + val avatarSize = configuration.percentOfHeight(0.2f) + Box { + CircleishAvatar(state.me.avatarUrl?.value, fallbackLabel, avatarSize) + + // TODO enable once edit support it added + if (false) { + IconButton(modifier = Modifier + .size(avatarSize * 0.314f) + .align(Alignment.BottomEnd) + .background(MaterialTheme.colors.primary, shape = CircleShape) + .padding(12.dp), + onClick = {} + ) { + Icon(Icons.Filled.CameraAlt, contentDescription = null, tint = MaterialTheme.colors.onPrimary) + } + } + } + } + Spacer(modifier = Modifier.fillMaxHeight(0.05f)) + + TextRow( + title = "Display name", + content = state.me.displayName ?: "Not set", + ) + TextRow( + title = "User id", + content = state.me.userId.value, + ) + TextRow( + title = "Homeserver", + content = state.me.homeServerUrl.value, + ) + } + } + } +} + +@Composable +private fun ProfileViewModel.ObserveEvents() { + val context = LocalContext.current + StartObserving { + this@ObserveEvents.events.launch { + when (it) { + } + } + } +} \ No newline at end of file diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileState.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileState.kt new file mode 100644 index 0000000..35be2c1 --- /dev/null +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileState.kt @@ -0,0 +1,14 @@ +package app.dapk.st.profile + +import app.dapk.st.matrix.room.ProfileService + +sealed interface ProfileScreenState { + object Loading : ProfileScreenState + data class Content(val me: ProfileService.Me) : ProfileScreenState + +} + +sealed interface ProfileEvent { + +} + diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt new file mode 100644 index 0000000..a4d7792 --- /dev/null +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt @@ -0,0 +1,30 @@ +package app.dapk.st.profile + +import androidx.lifecycle.viewModelScope +import app.dapk.st.core.DapkViewModel +import app.dapk.st.matrix.room.ProfileService +import kotlinx.coroutines.launch + +class ProfileViewModel( + private val profileService: ProfileService, +) : DapkViewModel( + initialState = ProfileScreenState.Loading +) { + + fun start() { + viewModelScope.launch { + val me = profileService.me(forceRefresh = true) + state = ProfileScreenState.Content(me) + } + } + + + fun updateDisplayName() { + // TODO + } + + fun updateAvatar() { + // TODO + } + +} diff --git a/features/settings/build.gradle b/features/settings/build.gradle new file mode 100644 index 0000000..dbd2e7d --- /dev/null +++ b/features/settings/build.gradle @@ -0,0 +1,11 @@ +applyAndroidLibraryModule(project) + +dependencies { + implementation project(":matrix:services:sync") + implementation project(":matrix:services:crypto") + implementation project(":features:navigator") + implementation project(':domains:store') + implementation project(":domains:android:core") + implementation project(":design-library") + implementation project(":core") +} \ No newline at end of file diff --git a/features/settings/src/main/AndroidManifest.xml b/features/settings/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f4e2a9a --- /dev/null +++ b/features/settings/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsActivity.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsActivity.kt new file mode 100644 index 0000000..f89d251 --- /dev/null +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsActivity.kt @@ -0,0 +1,30 @@ +package app.dapk.st.settings + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Surface +import androidx.compose.ui.Modifier +import app.dapk.st.core.DapkActivity +import app.dapk.st.core.module +import app.dapk.st.core.viewModel +import app.dapk.st.design.components.SmallTalkTheme + +class SettingsActivity : DapkActivity() { + + private val settingsViewModel by viewModel { module().settingsViewModel() } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + SmallTalkTheme { + Surface(Modifier.fillMaxSize()) { + SettingsScreen(settingsViewModel, onSignOut = { + navigator.navigate.toHome() + finish() + }, navigator) + } + } + } + } +} diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsModule.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsModule.kt new file mode 100644 index 0000000..c7422a6 --- /dev/null +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsModule.kt @@ -0,0 +1,32 @@ +package app.dapk.st.settings + +import android.content.ContentResolver +import app.dapk.st.core.BuildMeta +import app.dapk.st.core.ProvidableModule +import app.dapk.st.domain.StoreModule +import app.dapk.st.matrix.crypto.CryptoService +import app.dapk.st.matrix.sync.SyncService +import app.dapk.st.settings.eventlogger.EventLoggerViewModel + +class SettingsModule( + private val storeModule: StoreModule, + private val cryptoService: CryptoService, + private val syncService: SyncService, + private val contentResolver: ContentResolver, + private val buildMeta: BuildMeta, +) : ProvidableModule { + + fun settingsViewModel() = SettingsViewModel( + storeModule.credentialsStore(), + storeModule.cacheCleaner(), + contentResolver, + cryptoService, + syncService, + UriFilenameResolver(contentResolver), + buildMeta + ) + + fun eventLogViewModel(): EventLoggerViewModel { + return EventLoggerViewModel(storeModule.eventLogStore()) + } +} \ No newline at end of file diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsScreen.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsScreen.kt new file mode 100644 index 0000000..5fcb425 --- /dev/null +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsScreen.kt @@ -0,0 +1,226 @@ +package app.dapk.st.settings + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +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.platform.LocalContext +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.dapk.st.core.Lce +import app.dapk.st.core.StartObserving +import app.dapk.st.core.components.CenteredLoading +import app.dapk.st.core.components.Header +import app.dapk.st.design.components.SettingsTextRow +import app.dapk.st.design.components.Spider +import app.dapk.st.design.components.SpiderPage +import app.dapk.st.design.components.TextRow +import app.dapk.st.navigator.Navigator +import app.dapk.st.settings.SettingsEvent.* +import app.dapk.st.settings.eventlogger.EventLogActivity + +@Composable +fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit, navigator: Navigator) { + viewModel.ObserveEvents(onSignOut) + LaunchedEffect(true) { + viewModel.start() + } + + val onNavigate: (SpiderPage?) -> Unit = { + when (it) { + null -> navigator.navigate.upToHome() + else -> viewModel.goTo(it) + } + } + Spider(currentPage = viewModel.state.page, onNavigate = onNavigate) { + item(Page.Routes.root) { + RootSettings(it) { viewModel.onClick(it) } + } + item(Page.Routes.encryption) { + Encryption(viewModel, it) + } + item(Page.Routes.importRoomKeys) { + when (it.importProgress) { + null -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { + Column( + modifier = Modifier.fillMaxWidth(0.8f), + ) { + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { + it?.let { + viewModel.fileSelected(it) + } + } + + Button(modifier = Modifier.fillMaxWidth(), onClick = { launcher.launch("text/*") }) { + Text(text = "SELECT FILE".uppercase()) + } + + if (it.selectedFile != null) { + Spacer(modifier = Modifier.height(24.dp)) + Text("Importing file: ${it.selectedFile.name}", overflow = TextOverflow.Ellipsis, modifier = Modifier.fillMaxWidth()) + Spacer(modifier = Modifier.height(24.dp)) + + var passphrase by rememberSaveable { mutableStateOf("") } + var passwordVisibility by rememberSaveable { mutableStateOf(false) } + val startImportAction = { viewModel.importFromFileKeys(it.selectedFile.uri, passphrase) } + + TextField( + modifier = Modifier.fillMaxWidth(), + value = passphrase, + onValueChange = { passphrase = it }, + label = { Text("Passphrase") }, + singleLine = true, + leadingIcon = { + Icon(imageVector = Icons.Outlined.Lock, contentDescription = null) + }, + keyboardActions = KeyboardActions(onDone = { startImportAction() }), + keyboardOptions = KeyboardOptions(autoCorrect = false, imeAction = ImeAction.Done, keyboardType = KeyboardType.Password), + visualTransformation = if (passwordVisibility) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + val image = if (passwordVisibility) Icons.Filled.Visibility else Icons.Filled.VisibilityOff + IconButton(onClick = { passwordVisibility = !passwordVisibility }) { + Icon(imageVector = image, contentDescription = null) + } + } + ) + Spacer(modifier = Modifier.height(24.dp)) + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = startImportAction, + enabled = passphrase.isNotEmpty() + ) { + Text(text = "Import".uppercase()) + } + } + } + } + } + is Lce.Content -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = "Import success") + Button(onClick = { navigator.navigate.upToHome() }) { + Text(text = "Close".uppercase()) + } + } + } + } + is Lce.Error -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = "Import failed") + Button(onClick = { navigator.navigate.upToHome() }) { + Text(text = "Close".uppercase()) + } + } + } + } + is Lce.Loading -> CenteredLoading() + } + } + } +} + +@Composable +private fun RootSettings(page: Page.Root, onClick: (SettingItem) -> Unit) { + when (val content = page.content) { + is Lce.Content -> { + LazyColumn( + Modifier + .padding(bottom = 12.dp) + .fillMaxWidth() + ) { + items(content.value) { item -> + when (item) { + is SettingItem.Text -> { + val itemOnClick = onClick.takeIf { item.id != SettingItem.Id.Ignored }?.let { + { it.invoke(item) } + } + + SettingsTextRow(item.content, item.subtitle, itemOnClick) + } + is SettingItem.AccessToken -> { + Row( + Modifier + .padding(start = 24.dp, end = 24.dp) + .clickable { onClick(item) }) { + Column { + Spacer(Modifier.height(24.dp)) + Text(text = item.content, fontSize = 24.sp) + Spacer(Modifier.height(24.dp)) + Divider( + color = Color.Gray, modifier = Modifier + .height(1.dp) + .alpha(0.2f) + ) + } + } + } + is SettingItem.Header -> Header(item.label) + } + } + item { Spacer(Modifier.height(12.dp)) } + } + } + } +} + +@Composable +private fun Encryption(viewModel: SettingsViewModel, page: Page.Security) { + Column { + TextRow("Import room keys", includeDivider = false) { + viewModel.goToImportRoom() + } + } +} + +@Composable +private fun SettingsViewModel.ObserveEvents(onSignOut: () -> Unit) { + val context = LocalContext.current + StartObserving { + this@ObserveEvents.events.launch { + when (it) { + SignedOut -> onSignOut() + is CopyToClipboard -> { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("dapk token", it.content)) + Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show() + } + is SettingsEvent.Toast -> Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show() + OpenEventLog -> { + context.startActivity(Intent(context, EventLogActivity::class.java)) + } + } + } + } +} diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsState.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsState.kt new file mode 100644 index 0000000..9314e52 --- /dev/null +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsState.kt @@ -0,0 +1,57 @@ +package app.dapk.st.settings + +import android.net.Uri +import app.dapk.st.core.Lce +import app.dapk.st.design.components.Route +import app.dapk.st.design.components.SpiderPage + +data class SettingsScreenState( + val page: SpiderPage, +) + +sealed interface Page { + data class Root(val content: Lce>) : Page + object Security : Page + data class ImportRoomKey( + val selectedFile: NamedUri? = null, + val importProgress: Lce? = null, + ) : Page + + object Routes { + val root = Route("Settings") + val encryption = Route("Encryption") + val importRoomKeys = Route("ImportRoomKey") + } +} + +data class NamedUri( + val name: String?, + val uri: Uri, +) + +sealed interface SettingItem { + + val id: Id + + data class Header(val label: String, override val id: Id = Id.Ignored) : SettingItem + data class Text(override val id: Id, val content: String, val subtitle: String? = null) : SettingItem + data class AccessToken(override val id: Id, val content: String, val accessToken: String) : SettingItem + + enum class Id { + SignOut, + AccessToken, + ClearCache, + EventLog, + Encryption, + Ignored, + } +} + +sealed interface SettingsEvent { + + object SignedOut : SettingsEvent + data class Toast(val message: String) : SettingsEvent + object OpenEventLog : SettingsEvent + data class CopyToClipboard(val message: String, val content: String) : SettingsEvent +} + diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsViewModel.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsViewModel.kt new file mode 100644 index 0000000..a4449b6 --- /dev/null +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsViewModel.kt @@ -0,0 +1,117 @@ +package app.dapk.st.settings + +import android.content.ContentResolver +import android.net.Uri +import androidx.lifecycle.viewModelScope +import app.dapk.st.core.BuildMeta +import app.dapk.st.core.DapkViewModel +import app.dapk.st.core.Lce +import app.dapk.st.design.components.SpiderPage +import app.dapk.st.domain.StoreCleaner +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.crypto.CryptoService +import app.dapk.st.matrix.sync.SyncService +import app.dapk.st.settings.SettingItem.Id.* +import app.dapk.st.settings.SettingsEvent.* +import kotlinx.coroutines.launch + +class SettingsViewModel( + private val credentialsStore: CredentialsStore, + private val cacheCleaner: StoreCleaner, + private val contentResolver: ContentResolver, + private val cryptoService: CryptoService, + private val syncService: SyncService, + private val uriFilenameResolver: UriFilenameResolver, + private val buildMeta: BuildMeta, +) : DapkViewModel( + initialState = SettingsScreenState(SpiderPage(Page.Routes.root, "Settings", null, Page.Root(Lce.Loading()))) +) { + + fun start() { + viewModelScope.launch { + val root = Page.Root( + Lce.Content( + listOf( + SettingItem.Header("General"), + SettingItem.Text(Encryption, "Encryption"), + SettingItem.Text(EventLog, "Event log"), + SettingItem.Header("Account"), + SettingItem.Text(SignOut, "Sign out"), + SettingItem.Header("About"), + SettingItem.Text(Ignored, "Version", buildMeta.versionName), + ) + ) + ) + val rootPage = SpiderPage(Page.Routes.root, "Settings", null, root) + updateState { copy(page = rootPage) } + } + } + + fun goTo(page: SpiderPage) { + updateState { copy(page = page) } + } + + fun onClick(item: SettingItem) { + when (item.id) { + SignOut -> { + viewModelScope.launch { credentialsStore.clear() } + _events.tryEmit(SignedOut) + } + AccessToken -> { + require(item is SettingItem.AccessToken) + _events.tryEmit(CopyToClipboard("Token copied", item.accessToken)) + } + ClearCache -> { + viewModelScope.launch { + cacheCleaner.cleanCache(removeCredentials = false) + _events.tryEmit(Toast(message = "Cache deleted")) + } + } + EventLog -> { + _events.tryEmit(OpenEventLog) + } + Encryption -> { + updateState { + copy(page = SpiderPage(Page.Routes.encryption, "Encryption", Page.Routes.root, Page.Security)) + } + } + } + } + + fun importFromFileKeys(file: Uri, passphrase: String) { + updatePageState { copy(importProgress = Lce.Loading()) } + viewModelScope.launch { + kotlin.runCatching { + with(cryptoService) { + val roomsToRefresh = contentResolver.openInputStream(file)?.importRoomKeys(passphrase) + roomsToRefresh?.let { syncService.forceManualRefresh(roomsToRefresh) } + } + }.fold( + onSuccess = { updatePageState { copy(importProgress = Lce.Content(true)) } }, + onFailure = { updatePageState { copy(importProgress = Lce.Error(it)) } } + ) + } + } + + fun goToImportRoom() { + updateState { + copy(page = SpiderPage(Page.Routes.importRoomKeys, "Import room keys", Page.Routes.encryption, Page.ImportRoomKey())) + } + } + + fun fileSelected(file: Uri) { + val namedFile = NamedUri( + name = uriFilenameResolver.readFilenameFromUri(file), + uri = file + ) + updatePageState { copy(selectedFile = namedFile) } + } + + @Suppress("UNCHECKED_CAST") + private inline fun updatePageState(crossinline block: S.() -> S) { + val page = state.page + val currentState = page.state + require(currentState is S) + updateState { copy(page = (page as SpiderPage).copy(state = block(page.state))) } + } +} diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/UriFilenameResolver.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/UriFilenameResolver.kt new file mode 100644 index 0000000..7297339 --- /dev/null +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/UriFilenameResolver.kt @@ -0,0 +1,29 @@ +package app.dapk.st.settings + +import android.content.ContentResolver +import android.net.Uri +import android.provider.OpenableColumns + +class UriFilenameResolver(private val contentResolver: ContentResolver) { + + fun readFilenameFromUri(uri: Uri): String { + val fallback = uri.path?.substringAfterLast('/') ?: throw IllegalStateException("expecting a file uri but got $uri") + return when (uri.scheme) { + "content" -> readResolvedDisplayName(uri) ?: fallback + else -> fallback + } + } + + private fun readResolvedDisplayName(uri: Uri): String? { + return contentResolver.query(uri, null, null, null, null)?.use { cursor -> + when { + cursor.moveToFirst() -> { + cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + .takeIf { it != -1 } + ?.let { cursor.getString(it) } + } + else -> null + } + } + } +} \ No newline at end of file diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLogActivity.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLogActivity.kt new file mode 100644 index 0000000..80e519d --- /dev/null +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLogActivity.kt @@ -0,0 +1,28 @@ +package app.dapk.st.settings.eventlogger + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Surface +import androidx.compose.ui.Modifier +import app.dapk.st.core.DapkActivity +import app.dapk.st.core.module +import app.dapk.st.core.viewModel +import app.dapk.st.settings.SettingsModule +import app.dapk.st.design.components.SmallTalkTheme + +class EventLogActivity : DapkActivity() { + + private val viewModel by viewModel { module().eventLogViewModel() } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + SmallTalkTheme { + Surface(Modifier.fillMaxSize()) { + EventLogScreen(viewModel) + } + } + } + } +} diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLogScreen.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLogScreen.kt new file mode 100644 index 0000000..885c985 --- /dev/null +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLogScreen.kt @@ -0,0 +1,114 @@ +package app.dapk.st.settings.eventlogger + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.dapk.st.core.AppLogTag +import app.dapk.st.core.Lce +import app.dapk.st.matrix.common.MatrixLogTag + +private val filterItems = listOf(null) + (MatrixLogTag.values().map { it.key } + AppLogTag.values().map { it.key }).distinct() + +@Composable +fun EventLogScreen(viewModel: EventLoggerViewModel) { + LaunchedEffect(true) { + viewModel.start() + } + + val state = viewModel.state + when (val keys = state.logs) { + is Lce.Content -> { + when (state.selectedState) { + null -> { + LogKeysList(keys.value) { + viewModel.selectLog(it, filter = null) + } + } + else -> { + Events( + selectedPageContent = state.selectedState, + onExit = { viewModel.exitLog() }, + onSelectTag = { viewModel.selectLog(state.selectedState.selectedPage, it) } + ) + } + } + } + } + +} + +@Composable +private fun LogKeysList(keys: List, onSelected: (String) -> Unit) { + LazyColumn { + items(keys) { + Text( + modifier = Modifier.fillMaxWidth().padding(8.dp).clickable { + onSelected(it) + }, + text = it, + fontSize = 32.sp, + ) + } + } +} + +@Composable +private fun Events(selectedPageContent: SelectedState, onExit: () -> Unit, onSelectTag: (String?) -> Unit) { + BackHandler(onBack = onExit) + when (val content = selectedPageContent.content) { + is Lce.Content -> { + Column { + Row { + var expanded by remember { mutableStateOf(false) } + Box { + Text( + modifier = Modifier.clickable { expanded = true }.padding(8.dp), + text = "Filter: ${selectedPageContent.filter ?: "all"}", + fontSize = 20.sp, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + filterItems.forEachIndexed { index, item -> + DropdownMenuItem( + onClick = { + expanded = false + onSelectTag(filterItems[index]) + } + ) { + Text(item ?: "all") + } + } + } + } + } + + LazyColumn(Modifier.weight(1f)) { + items(content.value) { + val text = when (selectedPageContent.filter) { + null -> "${it.time}: ${it.tag}: ${it.content}" + else -> "${it.time}: ${it.content}" + } + + Text( + text = text, + modifier = Modifier.padding(horizontal = 4.dp), + fontSize = 10.sp, + ) + } + } + } + } + is Lce.Error -> TODO() + is Lce.Loading -> { + // TODO + } + } +} \ No newline at end of file diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLoggerState.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLoggerState.kt new file mode 100644 index 0000000..60bf449 --- /dev/null +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLoggerState.kt @@ -0,0 +1,15 @@ +package app.dapk.st.settings.eventlogger + +import app.dapk.st.core.Lce +import app.dapk.st.domain.eventlog.LogLine + +data class EventLoggerState( + val logs: Lce>, + val selectedState: SelectedState?, +) + +data class SelectedState( + val selectedPage: String, + val content: Lce>, + val filter: String?, +) \ No newline at end of file diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLoggerViewModel.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLoggerViewModel.kt new file mode 100644 index 0000000..aa36107 --- /dev/null +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLoggerViewModel.kt @@ -0,0 +1,51 @@ +package app.dapk.st.settings.eventlogger + +import androidx.lifecycle.viewModelScope +import app.dapk.st.core.DapkViewModel +import app.dapk.st.core.Lce +import app.dapk.st.domain.eventlog.EventLogPersistence +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class EventLoggerViewModel( + private val persistence: EventLogPersistence +) : DapkViewModel( + initialState = EventLoggerState( + logs = Lce.Loading(), + selectedState = null, + ) +) { + + private var logObserverJob: Job? = null + + fun start() { + viewModelScope.launch { + updateState { copy(logs = Lce.Loading()) } + val days = persistence.days() + updateState { copy(logs = Lce.Content(days)) } + } + } + + fun selectLog(logKey: String, filter: String?) { + logObserverJob?.cancel() + updateState { copy(selectedState = SelectedState(selectedPage = logKey, content = Lce.Loading(), filter = filter)) } + + logObserverJob = viewModelScope.launch { + persistence.latest(logKey, filter) + .onEach { + updateState { copy(selectedState = selectedState?.copy(content = Lce.Content(it))) } + }.collect() + } + } + + override fun onCleared() { + logObserverJob?.cancel() + } + + fun exitLog() { + logObserverJob?.cancel() + updateState { copy(selectedState = null) } + } +} diff --git a/features/verification/build.gradle b/features/verification/build.gradle new file mode 100644 index 0000000..42e3d5c --- /dev/null +++ b/features/verification/build.gradle @@ -0,0 +1,8 @@ +applyAndroidLibraryModule(project) + +dependencies { + implementation project(":matrix:services:crypto") + implementation project(":domains:android:core") + implementation project(":design-library") + implementation project(":core") +} \ No newline at end of file diff --git a/features/verification/src/main/AndroidManifest.xml b/features/verification/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0b6ec3c --- /dev/null +++ b/features/verification/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/features/verification/src/main/kotlin/app/dapk/st/verification/VerificationActivity.kt b/features/verification/src/main/kotlin/app/dapk/st/verification/VerificationActivity.kt new file mode 100644 index 0000000..fbb230d --- /dev/null +++ b/features/verification/src/main/kotlin/app/dapk/st/verification/VerificationActivity.kt @@ -0,0 +1,27 @@ +package app.dapk.st.verification + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Surface +import androidx.compose.ui.Modifier +import app.dapk.st.design.components.SmallTalkTheme +import app.dapk.st.core.DapkActivity +import app.dapk.st.core.module +import app.dapk.st.core.viewModel + +class VerificationActivity : DapkActivity() { + + private val verificationViewModel by viewModel { module().verificationViewModel() } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + SmallTalkTheme { + Surface(Modifier.fillMaxSize()) { + VerificationScreen(verificationViewModel) + } + } + } + } +} diff --git a/features/verification/src/main/kotlin/app/dapk/st/verification/VerificationModule.kt b/features/verification/src/main/kotlin/app/dapk/st/verification/VerificationModule.kt new file mode 100644 index 0000000..1e76f7e --- /dev/null +++ b/features/verification/src/main/kotlin/app/dapk/st/verification/VerificationModule.kt @@ -0,0 +1,14 @@ +package app.dapk.st.verification + +import app.dapk.st.core.ProvidableModule +import app.dapk.st.matrix.crypto.CryptoService + +class VerificationModule( + private val cryptoService: CryptoService +) : ProvidableModule { + + fun verificationViewModel(): VerificationViewModel { + return VerificationViewModel(cryptoService) + } + +} \ No newline at end of file diff --git a/features/verification/src/main/kotlin/app/dapk/st/verification/VerificationScreen.kt b/features/verification/src/main/kotlin/app/dapk/st/verification/VerificationScreen.kt new file mode 100644 index 0000000..b9c40a4 --- /dev/null +++ b/features/verification/src/main/kotlin/app/dapk/st/verification/VerificationScreen.kt @@ -0,0 +1,29 @@ +package app.dapk.st.verification + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable + +@Composable +fun VerificationScreen(viewModel: VerificationViewModel) { + + + Column { + Text("Verification request") + + + Row { + Button(onClick = { + viewModel.inSecureAccept() + }) { + Text("Yes".uppercase()) + } + } + + + } + + +} \ No newline at end of file diff --git a/features/verification/src/main/kotlin/app/dapk/st/verification/VerificationState.kt b/features/verification/src/main/kotlin/app/dapk/st/verification/VerificationState.kt new file mode 100644 index 0000000..1630dea --- /dev/null +++ b/features/verification/src/main/kotlin/app/dapk/st/verification/VerificationState.kt @@ -0,0 +1,8 @@ +package app.dapk.st.verification + +data class VerificationScreenState(val foo: String) + +sealed interface VerificationEvent { + +} + diff --git a/features/verification/src/main/kotlin/app/dapk/st/verification/VerificationViewModel.kt b/features/verification/src/main/kotlin/app/dapk/st/verification/VerificationViewModel.kt new file mode 100644 index 0000000..8570bd9 --- /dev/null +++ b/features/verification/src/main/kotlin/app/dapk/st/verification/VerificationViewModel.kt @@ -0,0 +1,21 @@ +package app.dapk.st.verification + +import androidx.lifecycle.viewModelScope +import app.dapk.st.core.DapkViewModel +import app.dapk.st.matrix.crypto.CryptoService +import app.dapk.st.matrix.crypto.Verification +import kotlinx.coroutines.launch + +class VerificationViewModel( + private val cryptoService: CryptoService, +) : DapkViewModel( + initialState = VerificationScreenState(foo = "") +) { + fun inSecureAccept() { + viewModelScope.launch { + cryptoService.verificationAction(Verification.Action.InsecureAccept) + } + } + + +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..895b20c --- /dev/null +++ b/gradle.properties @@ -0,0 +1,23 @@ +kotlin.code.style=official + +org.gradle.jvmargs=-Xmx6144M -Xms2048M -Dfile.encoding=UTF-8 -XX:+UseParallelGC +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.unsafe.configuration-cache=true +org.gradle.caching=true +org.gradle.configureondemand=true +org.gradle.vfs.watch=true + +android.useAndroidX=true +android.enableJetifier=false +android.debug.obsoleteApi=false +android.injected.build.model.only.versioned=3 +android.enableResourceOptimizations=true +android.enableR8.fullMode=true +android.nonTransitiveRClass=true +android.disableAutomaticComponentCreation=true +#android.experimental.enableNewResourceShrinker.preciseShrinking=true + +kapt.incremental.apt=true +kapt.use.worker.api=true +kapt.include.compile.classpath=false diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..7454180f2ae8848c63b8b4dea2cb829da983f2fa GIT binary patch literal 59536 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 QuEJmfE|#`Dqx|px0L@K;Y5)KL literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..41dfb87 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/matrix/common/build.gradle b/matrix/common/build.gradle new file mode 100644 index 0000000..73df1df --- /dev/null +++ b/matrix/common/build.gradle @@ -0,0 +1,13 @@ +plugins { + id 'kotlin' + id 'org.jetbrains.kotlin.plugin.serialization' + id 'java-test-fixtures' +} + +dependencies { + implementation Dependencies.mavenCentral.kotlinSerializationJson + + kotlinTest(it) + kotlinFixtures(it) + testFixturesImplementation(testFixtures(project(":core"))) +} \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/AlgorithmName.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/AlgorithmName.kt new file mode 100644 index 0000000..c2b758e --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/AlgorithmName.kt @@ -0,0 +1,7 @@ +package app.dapk.st.matrix.common + +import kotlinx.serialization.Serializable + +@Serializable +@JvmInline +value class AlgorithmName(val value: String) \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/AvatarUrl.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/AvatarUrl.kt new file mode 100644 index 0000000..66a1a8f --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/AvatarUrl.kt @@ -0,0 +1,7 @@ +package app.dapk.st.matrix.common + +import kotlinx.serialization.Serializable + +@Serializable +@JvmInline +value class AvatarUrl(val value: String) \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/CipherText.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/CipherText.kt new file mode 100644 index 0000000..f40338a --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/CipherText.kt @@ -0,0 +1,7 @@ +package app.dapk.st.matrix.common + +import kotlinx.serialization.Serializable + +@Serializable +@JvmInline +value class CipherText(val value: String) \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/CredentialsStore.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/CredentialsStore.kt new file mode 100644 index 0000000..6c289a2 --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/CredentialsStore.kt @@ -0,0 +1,10 @@ +package app.dapk.st.matrix.common + +interface CredentialsStore { + + suspend fun credentials(): UserCredentials? + suspend fun update(credentials: UserCredentials) + suspend fun clear() +} + +suspend fun CredentialsStore.isSignedIn() = this.credentials() != null diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/Curve25519.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/Curve25519.kt new file mode 100644 index 0000000..55561b8 --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/Curve25519.kt @@ -0,0 +1,7 @@ +package app.dapk.st.matrix.common + +import kotlinx.serialization.Serializable + +@Serializable +@JvmInline +value class Curve25519(val value: String) \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/DecryptionResult.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/DecryptionResult.kt new file mode 100644 index 0000000..d5d1b9f --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/DecryptionResult.kt @@ -0,0 +1,6 @@ +package app.dapk.st.matrix.common + +sealed interface DecryptionResult { + data class Failed(val reason: String) : DecryptionResult + data class Success(val payload: JsonString, val isVerified: Boolean) : DecryptionResult +} diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/DeviceId.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/DeviceId.kt new file mode 100644 index 0000000..d353145 --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/DeviceId.kt @@ -0,0 +1,7 @@ +package app.dapk.st.matrix.common + +import kotlinx.serialization.Serializable + +@Serializable +@JvmInline +value class DeviceId(val value: String) \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/Ed25519.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/Ed25519.kt new file mode 100644 index 0000000..5a802cb --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/Ed25519.kt @@ -0,0 +1,7 @@ +package app.dapk.st.matrix.common + +import kotlinx.serialization.Serializable + +@Serializable +@JvmInline +value class Ed25519(val value: String) \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/EncryptedMessageContent.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/EncryptedMessageContent.kt new file mode 100644 index 0000000..9f958bd --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/EncryptedMessageContent.kt @@ -0,0 +1,22 @@ +package app.dapk.st.matrix.common + +sealed class EncryptedMessageContent { + + data class OlmV1( + val senderId: UserId, + val cipherText: Map, + val senderKey: Curve25519, + ) : EncryptedMessageContent() + + data class MegOlmV1( + val cipherText: CipherText, + val deviceId: DeviceId, + val senderKey: String, + val sessionId: SessionId, + ) : EncryptedMessageContent() + + data class CipherTextInfo( + val body: CipherText, + val type: Int, + ) +} \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/EventId.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/EventId.kt new file mode 100644 index 0000000..1c4c378 --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/EventId.kt @@ -0,0 +1,7 @@ +package app.dapk.st.matrix.common + +import kotlinx.serialization.Serializable + +@Serializable +@JvmInline +value class EventId(val value: String) \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/EventType.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/EventType.kt new file mode 100644 index 0000000..4f1c309 --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/EventType.kt @@ -0,0 +1,18 @@ +package app.dapk.st.matrix.common + +enum class EventType(val value: String) { + ROOM_MESSAGE("m.room.message"), + ENCRYPTED("m.room.encrypted"), + ENCRYPTION("m.room.encryption"), + VERIFICATION_REQUEST("m.key.verification.request"), + VERIFICATION_READY("m.key.verification.ready"), + VERIFICATION_START("m.key.verification.start"), + VERIFICATION_ACCEPT("m.key.verification.accept"), + VERIFICATION_MAC("m.key.verification.mac"), + VERIFICATION_KEY("m.key.verification.key"), + VERIFICATION_DONE("m.key.verification.done"), +} + +enum class MessageType(val value: String) { + TEXT("m.text") +} \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/HomeServerUrl.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/HomeServerUrl.kt new file mode 100644 index 0000000..d721b18 --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/HomeServerUrl.kt @@ -0,0 +1,7 @@ +package app.dapk.st.matrix.common + +import kotlinx.serialization.Serializable + +@Serializable +@JvmInline +value class HomeServerUrl(val value: String) \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/JsonCanonicalizer.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/JsonCanonicalizer.kt new file mode 100644 index 0000000..7fa7acf --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/JsonCanonicalizer.kt @@ -0,0 +1,28 @@ +package app.dapk.st.matrix.common + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +private val CAPTURE_UNICODE = "\\\\u(.{4})".toRegex() + +class JsonCanonicalizer { + + fun canonicalize(input: JsonString): String { + val element = Json.parseToJsonElement(input.value.replace(CAPTURE_UNICODE, " ")).sort() + return Json.encodeToString(element) + } + +} + +private fun JsonElement.sort(): JsonElement { + return when (this) { + is JsonObject -> JsonObject( + this.map { it.key to it.value.sort() }.sortedBy { it.first }.toMap() + ) + is JsonArray -> JsonArray(this.map { it.sort() }) + else -> this + } +} \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/JsonString.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/JsonString.kt new file mode 100644 index 0000000..86a4636 --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/JsonString.kt @@ -0,0 +1,4 @@ +package app.dapk.st.matrix.common + +@JvmInline +value class JsonString(val value: String) \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/MatrixLogger.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/MatrixLogger.kt new file mode 100644 index 0000000..ad710ca --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/MatrixLogger.kt @@ -0,0 +1,30 @@ +package app.dapk.st.matrix.common + +enum class MatrixLogTag(val key: String) { + MATRIX("matrix"), + CRYPTO("crypto"), + SYNC("sync"), + VERIFICATION("verification"), + PERF("performance"), + ROOM("room"), +} + +typealias MatrixLogger = (tag: String, message: String) -> Unit + +fun MatrixLogger.crypto(message: Any) = this.matrixLog(MatrixLogTag.CRYPTO, message) + +fun MatrixLogger.matrixLog(tag: MatrixLogTag, message: Any) { + this.invoke(tag.key, message.toString()) +} + +fun MatrixLogger.matrixLog(message: Any) { + matrixLog(tag = MatrixLogTag.MATRIX, message = message) +} + +suspend fun MatrixLogger.logP(area: String, block: suspend () -> T): T { + val start = System.currentTimeMillis() + return block().also { + val timeTaken = System.currentTimeMillis() - start + matrixLog(MatrixLogTag.PERF, "$area: took $timeTaken ms") + } +} \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/MxUrl.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/MxUrl.kt new file mode 100644 index 0000000..45bf1cf --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/MxUrl.kt @@ -0,0 +1,19 @@ +package app.dapk.st.matrix.common + +import kotlinx.serialization.Serializable +import java.net.URI + +@Serializable +@JvmInline +value class MxUrl(val value: String) + +fun MxUrl.convertMxUrToUrl(homeServer: HomeServerUrl): String { + val mxcUri = URI.create(this.value) + return "${homeServer.value.ensureHttps().ensureEndsWith("/")}_matrix/media/r0/download/${mxcUri.authority}${mxcUri.path}" +} + +private fun String.ensureEndsWith(suffix: String) = if (endsWith(suffix)) this else "$this$suffix" + +private fun String.ensureHttps() = replace("http://", "https://").ensureStartsWith("https://") + +private fun String.ensureStartsWith(prefix: String) = if (startsWith(prefix)) this else "$prefix$this" diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RoomId.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RoomId.kt new file mode 100644 index 0000000..5720b81 --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RoomId.kt @@ -0,0 +1,7 @@ +package app.dapk.st.matrix.common + +import kotlinx.serialization.Serializable + +@Serializable +@JvmInline +value class RoomId(val value: String) \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RoomMember.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RoomMember.kt new file mode 100644 index 0000000..fb58fb0 --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RoomMember.kt @@ -0,0 +1,11 @@ +package app.dapk.st.matrix.common + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RoomMember( + @SerialName("user_id") val id: UserId, + @SerialName("display_name") val displayName: String?, + @SerialName("avatar_url") val avatarUrl: AvatarUrl?, +) \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/ServerKeyCount.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/ServerKeyCount.kt new file mode 100644 index 0000000..aab83a0 --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/ServerKeyCount.kt @@ -0,0 +1,7 @@ +package app.dapk.st.matrix.common + +import kotlinx.serialization.Serializable + +@Serializable +@JvmInline +value class ServerKeyCount(val value: Int) diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/SessionId.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/SessionId.kt new file mode 100644 index 0000000..e143de5 --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/SessionId.kt @@ -0,0 +1,7 @@ +package app.dapk.st.matrix.common + +import kotlinx.serialization.Serializable + +@Serializable +@JvmInline +value class SessionId(val value: String) \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/SharedRoomKey.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/SharedRoomKey.kt new file mode 100644 index 0000000..81386bc --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/SharedRoomKey.kt @@ -0,0 +1,9 @@ +package app.dapk.st.matrix.common + +data class SharedRoomKey( + val algorithmName: AlgorithmName, + val roomId: RoomId, + val sessionId: SessionId, + val sessionKey: String, + val isExported: Boolean, +) \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/SignedJson.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/SignedJson.kt new file mode 100644 index 0000000..5b2224f --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/SignedJson.kt @@ -0,0 +1,4 @@ +package app.dapk.st.matrix.common + +@JvmInline +value class SignedJson(val value: String) \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/SyncToken.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/SyncToken.kt new file mode 100644 index 0000000..278bb13 --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/SyncToken.kt @@ -0,0 +1,7 @@ +package app.dapk.st.matrix.common + +import kotlinx.serialization.Serializable + +@Serializable +@JvmInline +value class SyncToken(val value: String) \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/UserCredentials.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/UserCredentials.kt new file mode 100644 index 0000000..8c6ac25 --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/UserCredentials.kt @@ -0,0 +1,25 @@ +package app.dapk.st.matrix.common + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class UserCredentials( + @SerialName("access_token") val accessToken: String, + @SerialName("home_server") val homeServer: HomeServerUrl, + @SerialName("user_id") override val userId: UserId, + @SerialName("device_id") override val deviceId: DeviceId, +) : DeviceCredentials { + + companion object { + + fun String.fromJson() = Json.decodeFromString(serializer(), this) + fun UserCredentials.toJson() = Json.encodeToString(serializer(), this) + } +} + +interface DeviceCredentials { + val userId: UserId + val deviceId: DeviceId +} diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/UserId.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/UserId.kt new file mode 100644 index 0000000..ced149f --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/UserId.kt @@ -0,0 +1,7 @@ +package app.dapk.st.matrix.common + +import kotlinx.serialization.Serializable + +@Serializable +@JvmInline +value class UserId(val value: String) \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/extensions/JsonStringExtensions.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/extensions/JsonStringExtensions.kt new file mode 100644 index 0000000..bd4ac22 --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/extensions/JsonStringExtensions.kt @@ -0,0 +1,19 @@ +package app.dapk.st.matrix.common.extensions + +import app.dapk.st.matrix.common.JsonString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.* + +fun Any?.toJsonString(): JsonString = JsonString(Json.encodeToString(this.toJsonElement())) + +private fun Any?.toJsonElement(): JsonElement = when (this) { + null -> JsonNull + is JsonElement -> this + is Number -> JsonPrimitive(this) + is Boolean -> JsonPrimitive(this) + is String -> JsonPrimitive(this) + is Array<*> -> JsonArray(map { it.toJsonElement() }) + is List<*> -> JsonArray(map { it.toJsonElement() }) + is Map<*, *> -> JsonObject(map { it.key.toString() to it.value.toJsonElement() }.toMap()) + else -> throw IllegalArgumentException("Unknown type: $this") +} diff --git a/matrix/common/src/test/kotlin/app/dapk/st/matrix/common/JsonCanonicalizerTest.kt b/matrix/common/src/test/kotlin/app/dapk/st/matrix/common/JsonCanonicalizerTest.kt new file mode 100644 index 0000000..fe483d3 --- /dev/null +++ b/matrix/common/src/test/kotlin/app/dapk/st/matrix/common/JsonCanonicalizerTest.kt @@ -0,0 +1,108 @@ +package app.dapk.st.matrix.common + +import org.amshove.kluent.ErrorCollectionMode +import org.amshove.kluent.errorCollector +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.throwCollectedErrors +import org.junit.Test + +class JsonCanonicalizerTest { + + private data class Case(val input: String, val expected: String) + + private val jsonCanonicalizer = JsonCanonicalizer() + + @Test + fun `canonicalises json strings`() { + val cases = listOf( + Case( + input = """{}""", + expected = """{}""", + ), + Case( + input = """ + { + "one": 1, + "two": "Two" + } + """.trimIndent(), + expected = """{"one":1,"two":"Two"}""" + ), + Case( + input = """ + { + "b": "2", + "a": "1" + } + """.trimIndent(), + expected = """{"a":"1","b":"2"}""" + ), + Case( + input = """{"b":"2","a":"1"}""", + expected = """{"a":"1","b":"2"}""" + ), + Case( + input = """ + { + "auth": { + "success": true, + "mxid": "@john.doe:example.com", + "profile": { + "display_name": "John Doe", + "three_pids": [ + { + "medium": "email", + "address": "john.doe@example.org" + }, + { + "medium": "msisdn", + "address": "123456789" + } + ] + } + } + } + """.trimIndent(), + expected = """{"auth":{"mxid":"@john.doe:example.com","profile":{"display_name":"John Doe","three_pids":[{"address":"john.doe@example.org","medium":"email"},{"address":"123456789","medium":"msisdn"}]},"success":true}}""", + ), + Case( + input = """ + { + "a": " " + } + """.trimIndent(), + expected = """{"a":" "}""", + ), + Case( + input = """ + { + "a": "\u65E5" + } + """.trimIndent(), + expected = """{"a":" "}""" + ), + Case( + input = """ + { + "a": null + } + """.trimIndent(), + expected = """{"a":null}""" + ) + ) + + runCases(cases) { (input, expected) -> + val result = jsonCanonicalizer.canonicalize(JsonString(input)) + + result shouldBeEqualTo expected + } + } +} + +private inline fun runCases(cases: List, action: (T) -> Unit) { + errorCollector.setCollectionMode(ErrorCollectionMode.Soft) + cases.forEach { + action(it) + } + errorCollector.throwCollectedErrors() +} \ No newline at end of file diff --git a/matrix/common/src/testFixtures/kotlin/fake/FakeCredentialsStore.kt b/matrix/common/src/testFixtures/kotlin/fake/FakeCredentialsStore.kt new file mode 100644 index 0000000..a29112c --- /dev/null +++ b/matrix/common/src/testFixtures/kotlin/fake/FakeCredentialsStore.kt @@ -0,0 +1,10 @@ +package fake + +import app.dapk.st.matrix.common.CredentialsStore +import io.mockk.coEvery +import io.mockk.mockk +import test.delegateReturn + +class FakeCredentialsStore : CredentialsStore by mockk() { + fun givenCredentials() = coEvery { credentials() }.delegateReturn() +} \ No newline at end of file diff --git a/matrix/common/src/testFixtures/kotlin/fake/FakeMatrixLogger.kt b/matrix/common/src/testFixtures/kotlin/fake/FakeMatrixLogger.kt new file mode 100644 index 0000000..c10c288 --- /dev/null +++ b/matrix/common/src/testFixtures/kotlin/fake/FakeMatrixLogger.kt @@ -0,0 +1,9 @@ +package fake + +import app.dapk.st.matrix.common.MatrixLogger + +class FakeMatrixLogger : MatrixLogger { + override fun invoke(tag: String, message: String) { + // do nothing + } +} \ No newline at end of file diff --git a/matrix/common/src/testFixtures/kotlin/fixture/DeviceCredentialsFixture.kt b/matrix/common/src/testFixtures/kotlin/fixture/DeviceCredentialsFixture.kt new file mode 100644 index 0000000..8aab75a --- /dev/null +++ b/matrix/common/src/testFixtures/kotlin/fixture/DeviceCredentialsFixture.kt @@ -0,0 +1,13 @@ +package fixture + +import app.dapk.st.matrix.common.DeviceCredentials +import app.dapk.st.matrix.common.DeviceId +import app.dapk.st.matrix.common.UserId + +fun aDeviceCredentials( + userId: UserId = aUserId(), + deviceId: DeviceId = aDeviceId(), +) = object : DeviceCredentials { + override val userId = userId + override val deviceId = deviceId +} \ No newline at end of file diff --git a/matrix/common/src/testFixtures/kotlin/fixture/ModelFixtures.kt b/matrix/common/src/testFixtures/kotlin/fixture/ModelFixtures.kt new file mode 100644 index 0000000..7271564 --- /dev/null +++ b/matrix/common/src/testFixtures/kotlin/fixture/ModelFixtures.kt @@ -0,0 +1,14 @@ +package fixture + +import app.dapk.st.matrix.common.* + +fun aUserId(value: String = "a-user-id") = UserId(value) +fun aRoomId(value: String = "a-room-id") = RoomId(value) +fun anEventId(value: String = "an-event-id") = EventId(value) +fun aDeviceId(value: String = "a-device-id") = DeviceId(value) +fun aSessionId(value: String = "a-session-id") = SessionId(value) +fun aCipherText(value: String = "cipher-content") = CipherText(value) +fun aCurve25519(value: String = "curve-value") = Curve25519(value) +fun aEd25519(value: String = "ed-value") = Ed25519(value) +fun anAlgorithmName(value: String = "an-algorithm") = AlgorithmName(value) +fun aJsonString(value: String = "{}") = JsonString(value) \ No newline at end of file diff --git a/matrix/common/src/testFixtures/kotlin/fixture/RoomMemberFixture.kt b/matrix/common/src/testFixtures/kotlin/fixture/RoomMemberFixture.kt new file mode 100644 index 0000000..cf21486 --- /dev/null +++ b/matrix/common/src/testFixtures/kotlin/fixture/RoomMemberFixture.kt @@ -0,0 +1,11 @@ +package fixture + +import app.dapk.st.matrix.common.AvatarUrl +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.common.UserId + +fun aRoomMember( + id: UserId = aUserId(), + displayName: String? = null, + avatarUrl: AvatarUrl? = null +) = RoomMember(id, displayName, avatarUrl) \ No newline at end of file diff --git a/matrix/common/src/testFixtures/kotlin/fixture/UserCredentialsFixture.kt b/matrix/common/src/testFixtures/kotlin/fixture/UserCredentialsFixture.kt new file mode 100644 index 0000000..081b3f1 --- /dev/null +++ b/matrix/common/src/testFixtures/kotlin/fixture/UserCredentialsFixture.kt @@ -0,0 +1,13 @@ +package fixture + +import app.dapk.st.matrix.common.DeviceId +import app.dapk.st.matrix.common.HomeServerUrl +import app.dapk.st.matrix.common.UserCredentials +import app.dapk.st.matrix.common.UserId + +fun aUserCredentials( + accessToken: String = "an-access-token", + homeServer: HomeServerUrl = HomeServerUrl("homserver-url"), + userId: UserId = aUserId(), + deviceId: DeviceId = aDeviceId(), +) = UserCredentials(accessToken, homeServer, userId, deviceId) \ No newline at end of file diff --git a/matrix/matrix-http-ktor/build.gradle b/matrix/matrix-http-ktor/build.gradle new file mode 100644 index 0000000..00105fb --- /dev/null +++ b/matrix/matrix-http-ktor/build.gradle @@ -0,0 +1,13 @@ +plugins { + id 'kotlin' +} + +dependencies { + implementation project(":matrix:common") + api project(":matrix:matrix-http") + + implementation Dependencies.mavenCentral.ktorCore + implementation Dependencies.mavenCentral.ktorSerialization + implementation Dependencies.mavenCentral.ktorLogging + implementation Dependencies.mavenCentral.kotlinSerializationJson +} \ No newline at end of file diff --git a/matrix/matrix-http-ktor/src/main/kotlin/app/dapk/st/matrix/http/ktor/KtorMatrixHttpClientFactory.kt b/matrix/matrix-http-ktor/src/main/kotlin/app/dapk/st/matrix/http/ktor/KtorMatrixHttpClientFactory.kt new file mode 100644 index 0000000..2ccb558 --- /dev/null +++ b/matrix/matrix-http-ktor/src/main/kotlin/app/dapk/st/matrix/http/ktor/KtorMatrixHttpClientFactory.kt @@ -0,0 +1,34 @@ +package app.dapk.st.matrix.http.ktor + +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.http.ktor.internal.KtorMatrixHttpClient +import io.ktor.client.* +import io.ktor.client.features.json.* +import io.ktor.client.features.json.serializer.* +import io.ktor.client.features.logging.* +import io.ktor.http.* +import kotlinx.serialization.json.Json + +class KtorMatrixHttpClientFactory( + private val credentialsStore: CredentialsStore, + private val includeLogging: Boolean, +) : MatrixHttpClient.Factory { + + override fun create(json: Json): MatrixHttpClient { + val client = HttpClient { + install(JsonFeature) { + serializer = KotlinxSerializer(json) + } + + if (includeLogging) { + install(Logging) { + logger = Logger.SIMPLE + level = LogLevel.ALL + } + } + } + return KtorMatrixHttpClient(client, credentialsStore) + } + +} \ No newline at end of file diff --git a/matrix/matrix-http-ktor/src/main/kotlin/app/dapk/st/matrix/http/ktor/internal/KtorMatrixHttpClient.kt b/matrix/matrix-http-ktor/src/main/kotlin/app/dapk/st/matrix/http/ktor/internal/KtorMatrixHttpClient.kt new file mode 100644 index 0000000..fe2725b --- /dev/null +++ b/matrix/matrix-http-ktor/src/main/kotlin/app/dapk/st/matrix/http/ktor/internal/KtorMatrixHttpClient.kt @@ -0,0 +1,106 @@ +package app.dapk.st.matrix.http.ktor.internal + +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.common.UserCredentials +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.http.MatrixHttpClient.Method +import io.ktor.client.* +import io.ktor.client.features.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* + +internal class KtorMatrixHttpClient( + private val client: HttpClient, + private val tokenProvider: CredentialsStore +) : MatrixHttpClient { + + @Suppress("UNCHECKED_CAST") + override suspend fun execute(request: MatrixHttpClient.HttpRequest): T { + return when { + !request.authenticated -> { + request.execute { buildRequest(credentials = null, request) } + } + else -> authenticatedRequest(request) + } + } + + private suspend fun authenticatedRequest(request: MatrixHttpClient.HttpRequest) = + when (val initialCredentials = tokenProvider.credentials()) { + null -> { + val credentials = authenticate() + request.execute { + buildAuthenticatedRequest( + request, + credentials + ) + } + } + else -> withTokenRetry(initialCredentials) { token -> + request.execute { + buildAuthenticatedRequest( + request, + token + ) + } + } + } + + private suspend fun withTokenRetry(originalCredentials: UserCredentials, request: suspend (UserCredentials) -> T): T { + return try { + request(originalCredentials) + } catch (error: ClientRequestException) { + if (error.response.status.value == 401) { + val token = authenticate() + request(token) + } else { + throw error + } + } + } + + + suspend fun authenticate(): UserCredentials { + throw Error() // TODO +// val tokenResult = client.request { buildRequest(AuthEndpoint.anonAccessToken()) } +// tokenProvider.update(tokenResult.accessToken) +// return tokenResult.accessToken + } + + private fun HttpRequestBuilder.buildRequest( + credentials: UserCredentials?, + request: MatrixHttpClient.HttpRequest + ) { + val host = + request.baseUrl ?: credentials?.homeServer?.value ?: throw Error() + this.url("$host${request.path}") + this.method = when (request.method) { + Method.GET -> HttpMethod.Get + Method.POST -> HttpMethod.Post + Method.DELETE -> HttpMethod.Delete + Method.PUT -> HttpMethod.Put + } + this.headers.apply { + request.headers.forEach { + append(it.first, it.second) + } + } + this.body = request.body + } + + private fun HttpRequestBuilder.buildAuthenticatedRequest( + request: MatrixHttpClient.HttpRequest, + credentials: UserCredentials + ) { + this.buildRequest(credentials, request) + this.headers.apply { + append(HttpHeaders.Authorization, "Bearer ${credentials.accessToken}") + } + } + + @Suppress("UNCHECKED_CAST") + private suspend fun MatrixHttpClient.HttpRequest.execute(requestBuilder: HttpRequestBuilder.() -> Unit): T { + return client.request { requestBuilder(this) }.call.receive(this.typeInfo) as T + } + +} diff --git a/matrix/matrix-http/build.gradle b/matrix/matrix-http/build.gradle new file mode 100644 index 0000000..7f3ef17 --- /dev/null +++ b/matrix/matrix-http/build.gradle @@ -0,0 +1,11 @@ +plugins { + id 'kotlin' + id 'java-test-fixtures' +} + +dependencies { + api Dependencies.mavenCentral.ktorCore + implementation Dependencies.mavenCentral.kotlinSerializationJson + + kotlinFixtures(it) +} \ No newline at end of file diff --git a/matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/HttpExtensions.kt b/matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/HttpExtensions.kt new file mode 100644 index 0000000..2074396 --- /dev/null +++ b/matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/HttpExtensions.kt @@ -0,0 +1,9 @@ +package app.dapk.st.matrix.http + +fun String.ensureTrailingSlash(): String { + return if (this.endsWith("/")) this else "$this/" +} + +fun String.ensureHttps(): String { + return if (this.startsWith("https")) this else "https://$this" +} diff --git a/matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/JsonExtensions.kt b/matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/JsonExtensions.kt new file mode 100644 index 0000000..58d9bc2 --- /dev/null +++ b/matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/JsonExtensions.kt @@ -0,0 +1,51 @@ +package app.dapk.st.matrix.http + +import io.ktor.http.* +import io.ktor.http.content.* +import kotlinx.serialization.KSerializer +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +fun jsonBody(serializer: KSerializer, payload: T, json: Json = MatrixHttpClient.json): OutgoingContent { + return EqualTextContent( + TextContent( + text = json.encodeToString(serializer, payload), + contentType = ContentType.Application.Json, + ) + ) +} + +inline fun jsonBody(payload: T, json: Json = MatrixHttpClient.json): OutgoingContent { + return EqualTextContent( + TextContent( + text = json.encodeToString(payload), + contentType = ContentType.Application.Json, + ) + ) +} + +fun emptyJsonBody(): OutgoingContent { + return EqualTextContent(TextContent("{}", ContentType.Application.Json)) +} + +class EqualTextContent( + private val textContent: TextContent, +) : OutgoingContent.ByteArrayContent() { + + override fun bytes() = textContent.bytes() + override val contentLength: Long + get() = textContent.contentLength + + override fun toString(): String = textContent.toString() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as EqualTextContent + if (!bytes().contentEquals(other.bytes())) return false + return true + } + + override fun hashCode() = bytes().hashCode() + +} diff --git a/matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/MatrixHttpClient.kt b/matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/MatrixHttpClient.kt new file mode 100644 index 0000000..85e5e7d --- /dev/null +++ b/matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/MatrixHttpClient.kt @@ -0,0 +1,56 @@ +package app.dapk.st.matrix.http + +import io.ktor.client.utils.* +import io.ktor.http.content.* +import io.ktor.util.reflect.* +import kotlinx.serialization.json.Json + +interface MatrixHttpClient { + + suspend fun execute(request: HttpRequest): T + + data class HttpRequest constructor( + val path: String, + val method: Method, + val body: OutgoingContent = EmptyContent, + val headers: List> = emptyList(), + val authenticated: Boolean = true, + val setAcceptLanguage: Boolean = true, + val baseUrl: String? = null, + val typeInfo: TypeInfo, + ) { + + companion object { + inline fun httpRequest( + path: String, + method: Method, + body: OutgoingContent = EmptyContent, + headers: List> = emptyList(), + authenticated: Boolean = true, + setAcceptLanguage: Boolean = true, + baseUrl: String? = null, + ) = HttpRequest( + path, + method, + body, + headers, + authenticated, + setAcceptLanguage, + baseUrl, + typeInfo = typeInfo() + ) + } + + } + + enum class Method { GET, POST, DELETE, PUT } + + companion object { + val json = Json + val jsonWithDefaults = Json { encodeDefaults = true } + } + + fun interface Factory { + fun create(json: Json): MatrixHttpClient + } +} diff --git a/matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/NullableJsonTransformingSerializer.kt b/matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/NullableJsonTransformingSerializer.kt new file mode 100644 index 0000000..5f17673 --- /dev/null +++ b/matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/NullableJsonTransformingSerializer.kt @@ -0,0 +1,26 @@ +package app.dapk.st.matrix.http + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement + +abstract class NullableJsonTransformingSerializer( + private val tSerializer: KSerializer, + private val deserializer: (JsonElement) -> JsonElement? +) : KSerializer { + + override val descriptor: SerialDescriptor get() = tSerializer.descriptor + + final override fun deserialize(decoder: Decoder): T? { + require(decoder is JsonDecoder) + val element = decoder.decodeJsonElement() + return deserializer(element)?.let { decoder.json.decodeFromJsonElement(tSerializer, it) } + } + + final override fun serialize(encoder: Encoder, value: T?) { + throw IllegalAccessError("serialize not supported") + } +} \ No newline at end of file diff --git a/matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/RequestExtensions.kt b/matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/RequestExtensions.kt new file mode 100644 index 0000000..e19b0bf --- /dev/null +++ b/matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/RequestExtensions.kt @@ -0,0 +1,7 @@ +package app.dapk.st.matrix.http + +fun queryMap(vararg params: Pair): String { + return params.filterNot { it.second == null }.joinToString(separator = "&") { (key, value) -> + "$key=${value}" + } +} diff --git a/matrix/matrix-http/src/testFixtures/kotlin/fake/FakeMatrixHttpClient.kt b/matrix/matrix-http/src/testFixtures/kotlin/fake/FakeMatrixHttpClient.kt new file mode 100644 index 0000000..ee97b55 --- /dev/null +++ b/matrix/matrix-http/src/testFixtures/kotlin/fake/FakeMatrixHttpClient.kt @@ -0,0 +1,11 @@ +package fake + +import app.dapk.st.matrix.http.MatrixHttpClient +import io.mockk.coEvery +import io.mockk.mockk + +class FakeMatrixHttpClient : MatrixHttpClient by mockk() { + fun given(request: MatrixHttpClient.HttpRequest, response: T) { + coEvery { execute(request) } returns response + } +} \ No newline at end of file diff --git a/matrix/matrix/build.gradle b/matrix/matrix/build.gradle new file mode 100644 index 0000000..e30048b --- /dev/null +++ b/matrix/matrix/build.gradle @@ -0,0 +1,9 @@ +plugins { + id 'kotlin' +} + +dependencies { + implementation project(":matrix:matrix-http") + implementation project(":matrix:common") + implementation Dependencies.mavenCentral.kotlinSerializationJson +} \ No newline at end of file diff --git a/matrix/matrix/src/main/kotlin/app/dapk/st/matrix/MatrixClient.kt b/matrix/matrix/src/main/kotlin/app/dapk/st/matrix/MatrixClient.kt new file mode 100644 index 0000000..24294eb --- /dev/null +++ b/matrix/matrix/src/main/kotlin/app/dapk/st/matrix/MatrixClient.kt @@ -0,0 +1,68 @@ +package app.dapk.st.matrix + +import app.dapk.st.matrix.MatrixTaskRunner.MatrixTask +import app.dapk.st.matrix.common.MatrixLogger +import app.dapk.st.matrix.http.MatrixHttpClient +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModuleBuilder + +class MatrixClient( + private val httpClientFactory: MatrixHttpClient.Factory, + private val logger: MatrixLogger, +) : MatrixServiceProvider { + + private val serviceInstaller = ServiceInstaller() + + fun install(scope: MatrixServiceInstaller.() -> Unit) { + serviceInstaller.install(httpClientFactory, logger, scope) + } + + override fun getService(key: ServiceKey): T { + return serviceInstaller.getService(key) + } + + suspend fun run(task: MatrixTask): MatrixTaskRunner.TaskResult { + return serviceInstaller.delegate(task) + } +} + +typealias ServiceKey = Any + +interface MatrixService { + fun interface Factory { + fun create(deps: ServiceDependencies): Pair + } +} + +data class ServiceDependencies( + val httpClient: MatrixHttpClient, + val json: Json, + val services: MatrixServiceProvider, + val logger: MatrixLogger, +) + +interface MatrixServiceInstaller { + fun serializers(builder: SerializersModuleBuilder.() -> Unit) + fun install(factory: MatrixService.Factory) +} + +interface MatrixServiceProvider { + fun getService(key: ServiceKey): T +} + +fun interface ServiceDepFactory { + fun create(services: MatrixServiceProvider): T +} + +interface MatrixTaskRunner { + suspend fun canRun(task: MatrixTask): Boolean = false + suspend fun run(task: MatrixTask): TaskResult = throw IllegalArgumentException("Should only be invoked if canRun == true") + + data class MatrixTask(val type: String, val jsonPayload: String) + + sealed interface TaskResult { + object Success : TaskResult + data class Failure(val canRetry: Boolean) : TaskResult + } + +} diff --git a/matrix/matrix/src/main/kotlin/app/dapk/st/matrix/ServiceInstaller.kt b/matrix/matrix/src/main/kotlin/app/dapk/st/matrix/ServiceInstaller.kt new file mode 100644 index 0000000..2e59c9c --- /dev/null +++ b/matrix/matrix/src/main/kotlin/app/dapk/st/matrix/ServiceInstaller.kt @@ -0,0 +1,60 @@ +package app.dapk.st.matrix + +import app.dapk.st.matrix.common.MatrixLogger +import app.dapk.st.matrix.http.MatrixHttpClient +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.SerializersModuleBuilder + +internal class ServiceInstaller { + + private val services = mutableMapOf() + private val serviceInstaller = object : MatrixServiceInstaller { + + val serviceCollector = mutableListOf() + val serializers = mutableListOf Unit>() + + override fun serializers(builder: SerializersModuleBuilder.() -> Unit) { + serializers.add(builder) + } + + override fun install(factory: MatrixService.Factory) { + serviceCollector.add(factory) + } + } + + fun install(httpClientFactory: MatrixHttpClient.Factory, logger: MatrixLogger, scope: MatrixServiceInstaller.() -> Unit) { + scope(serviceInstaller) + val json = Json { + isLenient = true + ignoreUnknownKeys = true + serializersModule = SerializersModule { + serviceInstaller.serializers.forEach { + it.invoke(this) + } + } + } + + val httpClient = httpClientFactory.create(json) + val serviceProvider = object : MatrixServiceProvider { + override fun getService(key: ServiceKey) = this@ServiceInstaller.getService(key) + } + serviceInstaller.serviceCollector.forEach { + val (key, service) = it.create(ServiceDependencies(httpClient, json, serviceProvider, logger)) + services[key] = service + } + } + + @Suppress("UNCHECKED_CAST") + fun getService(key: ServiceKey): T { + return services[key] as T + } + + suspend fun delegate(task: MatrixTaskRunner.MatrixTask): MatrixTaskRunner.TaskResult { + return services.values + .filterIsInstance() + .firstOrNull { it.canRun(task) }?.run(task) + ?: throw IllegalArgumentException("No service available to handle ${task.type}") + } + +} \ No newline at end of file diff --git a/matrix/services/auth/build.gradle b/matrix/services/auth/build.gradle new file mode 100644 index 0000000..3dcc229 --- /dev/null +++ b/matrix/services/auth/build.gradle @@ -0,0 +1 @@ +applyMatrixServiceModule(project) diff --git a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/AuthService.kt b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/AuthService.kt new file mode 100644 index 0000000..caa47d2 --- /dev/null +++ b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/AuthService.kt @@ -0,0 +1,32 @@ +package app.dapk.st.matrix.auth + +import app.dapk.st.matrix.MatrixClient +import app.dapk.st.matrix.MatrixService +import app.dapk.st.matrix.MatrixServiceInstaller +import app.dapk.st.matrix.auth.internal.DefaultAuthService +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.common.UserCredentials + +private val SERVICE_KEY = AuthService::class + +interface AuthService : MatrixService { + suspend fun login(userName: String, password: String): UserCredentials + suspend fun register(userName: String, password: String, homeServer: String): UserCredentials +} + +fun MatrixServiceInstaller.installAuthService( + credentialsStore: CredentialsStore, + authConfig: AuthConfig = AuthConfig(), +) { + this.install { (httpClient, json) -> + SERVICE_KEY to DefaultAuthService(httpClient, credentialsStore, json, authConfig) + } +} + +fun MatrixClient.authService(): AuthService = this.getService(key = SERVICE_KEY) + + +data class AuthConfig( + val forceHttp: Boolean = false, + val forceHomeserver: String? = null +) diff --git a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/AuthRequest.kt b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/AuthRequest.kt new file mode 100644 index 0000000..015a7fa --- /dev/null +++ b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/AuthRequest.kt @@ -0,0 +1,101 @@ +package app.dapk.st.matrix.auth.internal + +import app.dapk.st.matrix.common.DeviceCredentials +import app.dapk.st.matrix.common.DeviceId +import app.dapk.st.matrix.common.HomeServerUrl +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.http.MatrixHttpClient.HttpRequest.Companion.httpRequest +import app.dapk.st.matrix.http.emptyJsonBody +import app.dapk.st.matrix.http.jsonBody +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +fun loginRequest(userId: UserId, password: String, baseUrl: String) = httpRequest( + path = "_matrix/client/r0/login", + method = MatrixHttpClient.Method.POST, + body = jsonBody( + PasswordLoginRequest.serializer(), + PasswordLoginRequest(PasswordLoginRequest.UserIdentifier(userId), password), + MatrixHttpClient.jsonWithDefaults + ), + authenticated = false, + baseUrl = baseUrl, +) + +fun registerStartFlowRequest(baseUrl: String) = httpRequest( + path = "_matrix/client/r0/register", + method = MatrixHttpClient.Method.POST, + body = emptyJsonBody(), + authenticated = false, + baseUrl = baseUrl, +) + +internal fun registerRequest(userName: String, password: String, baseUrl: String, auth: Auth?) = httpRequest( + path = "_matrix/client/r0/register", + method = MatrixHttpClient.Method.POST, + body = jsonBody( + PasswordRegisterRequest(userName, password, auth?.let { PasswordRegisterRequest.Auth(it.session, it.type) }), + MatrixHttpClient.jsonWithDefaults + ), + authenticated = false, + baseUrl = baseUrl, +) + +internal fun wellKnownRequest(baseUrl: String) = httpRequest( + path = ".well-known/matrix/client", + method = MatrixHttpClient.Method.GET, + baseUrl = baseUrl, + authenticated = false, +) + +internal data class Auth( + val session: String, + val type: String, +) + +@Serializable +data class ApiAuthResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("home_server") val homeServer: String, + @SerialName("user_id") override val userId: UserId, + @SerialName("device_id") override val deviceId: DeviceId, + @SerialName("well_known") val wellKnown: ApiWellKnown? = null, +) : DeviceCredentials + +@Serializable +data class ApiWellKnown( + @SerialName("m.homeserver") val homeServer: HomeServer +) { + @Serializable + data class HomeServer( + @SerialName("base_url") val baseUrl: HomeServerUrl, + ) +} + +@Serializable +internal data class PasswordLoginRequest( + @SerialName("identifier") val userName: UserIdentifier, + @SerialName("password") val password: String, + @SerialName("type") val type: String = "m.login.password", +) { + + @Serializable + internal data class UserIdentifier( + @SerialName("user") val userName: UserId, + @SerialName("type") val type: String = "m.id.user", + ) +} + +@Serializable +internal data class PasswordRegisterRequest( + @SerialName("username") val userName: String, + @SerialName("password") val password: String, + @SerialName("auth") val auth: Auth?, +) { + @Serializable + data class Auth( + @SerialName("session") val session: String, + @SerialName("type") val type: String, + ) +} diff --git a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/DefaultAuthService.kt b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/DefaultAuthService.kt new file mode 100644 index 0000000..b86eb87 --- /dev/null +++ b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/DefaultAuthService.kt @@ -0,0 +1,29 @@ +package app.dapk.st.matrix.auth.internal + +import app.dapk.st.matrix.auth.AuthConfig +import app.dapk.st.matrix.auth.AuthService +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.common.UserCredentials +import app.dapk.st.matrix.http.MatrixHttpClient +import kotlinx.serialization.json.Json + +internal class DefaultAuthService( + httpClient: MatrixHttpClient, + credentialsStore: CredentialsStore, + json: Json, + authConfig: AuthConfig, +) : AuthService { + + private val fetchWellKnownUseCase = FetchWellKnownUseCaseImpl(httpClient, json) + private val loginUseCase = LoginUseCase(httpClient, credentialsStore, fetchWellKnownUseCase, authConfig) + private val registerCase = RegisterUseCase(httpClient, credentialsStore, json, fetchWellKnownUseCase, authConfig) + + override suspend fun login(userName: String, password: String): UserCredentials { + return loginUseCase.login(userName, password) + } + + override suspend fun register(userName: String, password: String, homeServer: String): UserCredentials { + return registerCase.register(userName, password, homeServer) + } + +} \ No newline at end of file diff --git a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/FetchWellKnownUseCaseImpl.kt b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/FetchWellKnownUseCaseImpl.kt new file mode 100644 index 0000000..308447d --- /dev/null +++ b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/FetchWellKnownUseCaseImpl.kt @@ -0,0 +1,19 @@ +package app.dapk.st.matrix.auth.internal + +import app.dapk.st.matrix.http.MatrixHttpClient +import kotlinx.serialization.json.Json + +internal typealias FetchWellKnownUseCase = suspend (String) -> ApiWellKnown + +internal class FetchWellKnownUseCaseImpl( + private val httpClient: MatrixHttpClient, + private val json: Json, +) : FetchWellKnownUseCase { + + override suspend fun invoke(domainUrl: String): ApiWellKnown { + // workaround for matrix.org not returning a content-type + val raw = httpClient.execute(wellKnownRequest(domainUrl)) + return json.decodeFromString(ApiWellKnown.serializer(), raw) + } + +} \ No newline at end of file diff --git a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/LoginUseCase.kt b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/LoginUseCase.kt new file mode 100644 index 0000000..ac73a8a --- /dev/null +++ b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/LoginUseCase.kt @@ -0,0 +1,60 @@ +package app.dapk.st.matrix.auth.internal + +import app.dapk.st.matrix.auth.AuthConfig +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.common.HomeServerUrl +import app.dapk.st.matrix.common.UserCredentials +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.http.ensureTrailingSlash + +private const val MATRIX_DOT_ORG_DOMAIN = "matrix.org" + +class LoginUseCase( + private val httpClient: MatrixHttpClient, + private val credentialsProvider: CredentialsStore, + private val fetchWellKnownUseCase: FetchWellKnownUseCase, + private val authConfig: AuthConfig +) { + + suspend fun login(userName: String, password: String): UserCredentials { + val (domainUrl, fullUserId) = generateUserAccessInfo(userName) + val baseUrl = fetchWellKnownUseCase(domainUrl).homeServer.baseUrl.ensureTrailingSlash() + val authResponse = httpClient.execute(loginRequest(fullUserId, password, baseUrl.value)) + return UserCredentials( + authResponse.accessToken, + baseUrl, + authResponse.userId, + authResponse.deviceId, + ).also { credentialsProvider.update(it) } + } + + private fun generateUserAccessInfo(userName: String): Pair { + val cleanedUserName = userName.ensureStartsWithAt().trim() + val domain = cleanedUserName.findDomain(fallback = MATRIX_DOT_ORG_DOMAIN) + val domainUrl = domain.asHttpsUrl() + val fullUserId = cleanedUserName.ensureHasDomain(domain) + return Pair(domainUrl, UserId(fullUserId)) + } + + private fun String.findDomain(fallback: String) = this.substringAfter(":", missingDelimiterValue = fallback) + + private fun String.asHttpsUrl(): String { + val schema = when (authConfig.forceHttp) { + true -> "http://" + false -> "https://" + } + return "$schema$this".ensureTrailingSlash() + } +} + +private fun HomeServerUrl.ensureTrailingSlash() = HomeServerUrl(this.value.ensureTrailingSlash()) + +private fun String.ensureHasDomain(domain: String) = if (this.endsWith(domain)) this else "$this:$domain" + +private fun String.ensureStartsWithAt(): String { + return when (this.startsWith("@")) { + true -> this + false -> "@$this" + } +} diff --git a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/RegisterUseCase.kt b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/RegisterUseCase.kt new file mode 100644 index 0000000..e3438ab --- /dev/null +++ b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/RegisterUseCase.kt @@ -0,0 +1,71 @@ +package app.dapk.st.matrix.auth.internal + +import app.dapk.st.matrix.auth.AuthConfig +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.common.UserCredentials +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.http.ensureTrailingSlash +import io.ktor.client.features.* +import io.ktor.client.statement.* +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +class RegisterUseCase( + private val httpClient: MatrixHttpClient, + private val credentialsProvider: CredentialsStore, + private val json: Json, + private val fetchWellKnownUseCase: FetchWellKnownUseCase, + private val authConfig: AuthConfig, +) { + + suspend fun register(userName: String, password: String, homeServer: String): UserCredentials { + val baseUrl = homeServer.ifEmpty { "https://${userName.split(":").last()}/" }.ensureTrailingSlash() + + return try { + httpClient.execute(registerStartFlowRequest(baseUrl)) + throw IllegalStateException("the first request is expected to return a 401") + } catch (error: ClientRequestException) { + when (error.response.status.value) { + 401 -> { + val stage0 = json.decodeFromString(ApiUserInteractive.serializer(), error.response.readText()) + val supportsDummy = stage0.flows.any { it.stages.any { it == "m.login.dummy" } } + if (supportsDummy) { + registerAccount(userName, password, baseUrl, stage0.session) + } else { + throw error + } + } + else -> throw error + } + } + } + + private suspend fun registerAccount(userName: String, password: String, baseUrl: String, session: String): UserCredentials { + val authResponse = httpClient.execute( + registerRequest(userName, password, baseUrl, Auth(session, "m.login.dummy")) + ) + val homeServerUrl = when (authResponse.wellKnown == null) { + true -> fetchWellKnownUseCase(baseUrl).homeServer.baseUrl + false -> authResponse.wellKnown.homeServer.baseUrl + } + return UserCredentials( + authResponse.accessToken, + homeServerUrl, + authResponse.userId, + authResponse.deviceId, + ).also { credentialsProvider.update(it) } + } +} + +@Serializable +internal data class ApiUserInteractive( + @SerialName("flows") val flows: List, + @SerialName("session") val session: String, +) { + @Serializable + data class Flow( + @SerialName("stages") val stages: List + ) + +} \ No newline at end of file diff --git a/matrix/services/crypto/build.gradle b/matrix/services/crypto/build.gradle new file mode 100644 index 0000000..d9e49d4 --- /dev/null +++ b/matrix/services/crypto/build.gradle @@ -0,0 +1,17 @@ +plugins { id 'java-test-fixtures' } +applyMatrixServiceModule(project) + +dependencies { + implementation project(":core") + implementation project(":matrix:services:device") + + kotlinTest(it) + kotlinFixtures(it) + testImplementation(testFixtures(project(":matrix:common"))) + testImplementation(testFixtures(project(":matrix:matrix-http"))) + testImplementation(testFixtures(project(":core"))) + testImplementation(testFixtures(project(":matrix:services:device"))) + testFixturesImplementation(testFixtures(project(":matrix:common"))) + testFixturesImplementation(testFixtures(project(":matrix:services:device"))) + testFixturesImplementation(testFixtures(project(":core"))) +} \ No newline at end of file diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/CryptoService.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/CryptoService.kt new file mode 100644 index 0000000..08dcba2 --- /dev/null +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/CryptoService.kt @@ -0,0 +1,161 @@ +package app.dapk.st.matrix.crypto + +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.matrix.MatrixService +import app.dapk.st.matrix.MatrixServiceInstaller +import app.dapk.st.matrix.MatrixServiceProvider +import app.dapk.st.matrix.ServiceDepFactory +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.crypto.internal.* +import app.dapk.st.matrix.device.deviceService +import kotlinx.coroutines.flow.Flow +import java.io.InputStream + +private val SERVICE_KEY = CryptoService::class + +interface CryptoService : MatrixService { + suspend fun encrypt(roomId: RoomId, credentials: DeviceCredentials, messageJson: JsonString): Crypto.EncryptionResult + suspend fun decrypt(encryptedPayload: EncryptedMessageContent): DecryptionResult + suspend fun importRoomKeys(keys: List) + suspend fun InputStream.importRoomKeys(password: String): List + + suspend fun maybeCreateMoreKeys(serverKeyCount: ServerKeyCount) + suspend fun updateOlmSession(userIds: List, syncToken: SyncToken?) + + suspend fun onVerificationEvent(payload: Verification.Event) + suspend fun verificationAction(verificationAction: Verification.Action) + fun verificationState(): Flow +} + +interface Crypto { + + data class EncryptionResult( + val algorithmName: AlgorithmName, + val senderKey: String, + val cipherText: CipherText, + val sessionId: SessionId, + val deviceId: DeviceId + ) + +} + + +object Verification { + + sealed interface State { + object Idle : State + object ReadySent : State + object WaitingForMatchConfirmation : State + object WaitingForDoneConfirmation : State + object Done : State + } + + sealed interface Event { + + data class Requested( + val userId: UserId, + val deviceId: DeviceId, + val transactionId: String, + val methods: List, + val timestamp: Long, + ) : Event + + data class Ready( + val userId: UserId, + val deviceId: DeviceId, + val transactionId: String, + val methods: List, + ) : Event + + data class Started( + val userId: UserId, + val fromDevice: DeviceId, + val method: String, + val protocols: List, + val hashes: List, + val codes: List, + val short: List, + val transactionId: String, + ) : Event + + data class Accepted( + val userId: UserId, + val fromDevice: DeviceId, + val method: String, + val protocol: String, + val hash: String, + val code: String, + val short: List, + val transactionId: String, + ) : Event + + data class Key( + val userId: UserId, + val transactionId: String, + val key: String, + ) : Event + + data class Mac( + val userId: UserId, + val transactionId: String, + val keys: String, + val mac: Map, + ) : Event + + data class Done(val transactionId: String) : Event + + } + + sealed interface Action { + object SecureAccept : Action + object InsecureAccept : Action + object AcknowledgeMatch : Action + data class Request(val userId: UserId, val deviceId: DeviceId) : Action + } +} + +fun MatrixServiceInstaller.installCryptoService( + credentialsStore: CredentialsStore, + olm: Olm, + roomMembersProvider: ServiceDepFactory, + coroutineDispatchers: CoroutineDispatchers, +) { + this.install { (_, _, services, logger) -> + val deviceService = services.deviceService() + val accountCryptoUseCase = FetchAccountCryptoUseCaseImpl(credentialsStore, olm, deviceService) + + val registerOlmSessionUseCase = RegisterOlmSessionUseCaseImpl(olm, deviceService, logger) + val encryptMegolmUseCase = EncryptMessageWithMegolmUseCaseImpl( + olm, + FetchMegolmSessionUseCaseImpl( + olm, + deviceService, + accountCryptoUseCase, + roomMembersProvider.create(services), + registerOlmSessionUseCase, + ShareRoomKeyUseCaseImpl(credentialsStore, deviceService, logger, olm), + logger, + ), + logger, + ) + + val olmCrypto = OlmCrypto( + olm, + deviceService, + logger, + registerOlmSessionUseCase, + encryptMegolmUseCase, + accountCryptoUseCase, + MaybeCreateAndUploadOneTimeKeysUseCaseImpl(accountCryptoUseCase, olm, credentialsStore, deviceService, logger) + ) + val verificationHandler = VerificationHandler(deviceService, credentialsStore, logger, JsonCanonicalizer(), olm) + val roomKeyImporter = RoomKeyImporter(coroutineDispatchers) + SERVICE_KEY to DefaultCryptoService(olmCrypto, verificationHandler, roomKeyImporter, logger) + } +} + +fun MatrixServiceProvider.cryptoService(): CryptoService = this.getService(key = SERVICE_KEY) + +fun interface RoomMembersProvider { + suspend fun userIdsForRoom(roomId: RoomId): List +} \ No newline at end of file diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/Olm.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/Olm.kt new file mode 100644 index 0000000..0781e20 --- /dev/null +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/Olm.kt @@ -0,0 +1,88 @@ +package app.dapk.st.matrix.crypto + +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.device.DeviceService +import app.dapk.st.matrix.device.internal.DeviceKeys + +interface Olm { + + companion object { + val ALGORITHM_MEGOLM = AlgorithmName("m.megolm.v1.aes-sha2") + val ALGORITHM_OLM = AlgorithmName("m.olm.v1.curve25519-aes-sha2") + } + + suspend fun ensureAccountCrypto(deviceCredentials: DeviceCredentials, onCreate: suspend (AccountCryptoSession) -> Unit): AccountCryptoSession + suspend fun ensureRoomCrypto(roomId: RoomId, accountSession: AccountCryptoSession): RoomCryptoSession + suspend fun ensureDeviceCrypto(input: OlmSessionInput, olmAccount: AccountCryptoSession): DeviceCryptoSession + suspend fun import(keys: List) + + suspend fun DeviceCryptoSession.encrypt(messageJson: JsonString): EncryptionResult + suspend fun RoomCryptoSession.encrypt(roomId: RoomId, messageJson: JsonString): CipherText + suspend fun AccountCryptoSession.generateOneTimeKeys( + count: Int, + credentials: DeviceCredentials, + publishKeys: suspend (DeviceService.OneTimeKeys) -> Unit + ) + + suspend fun decryptOlm(olmAccount: AccountCryptoSession, senderKey: Curve25519, type: Long, body: CipherText): DecryptionResult + suspend fun decryptMegOlm(sessionId: SessionId, cipherText: CipherText): DecryptionResult + suspend fun verifyExternalUser(keys: Ed25519?, recipeientKeys: Ed25519?): Boolean + suspend fun olmSessions(devices: List, onMissing: suspend (List) -> List): List + suspend fun sasSession(deviceCredentials: DeviceCredentials): SasSession + + interface SasSession { + suspend fun generateCommitment(hash: String, startJsonString: String): String + suspend fun calculateMac( + selfUserId: UserId, + selfDeviceId: DeviceId, + otherUserId: UserId, + otherDeviceId: DeviceId, + transactionId: String + ): MacResult + + fun release() + fun publicKey(): String + fun setTheirPublicKey(key: String) + } + + data class MacResult(val mac: Map, val keys: String) + + data class EncryptionResult( + val cipherText: CipherText, + val type: Long, + ) + + data class OlmSessionInput( + val oneTimeKey: String, + val identity: Curve25519, + val deviceId: DeviceId, + val userId: UserId, + val fingerprint: Ed25519, + ) + + data class DeviceCryptoSession( + val deviceId: DeviceId, + val userId: UserId, + val identity: Curve25519, + val fingerprint: Ed25519, + val olmSession: List, + ) + + data class AccountCryptoSession( + val fingerprint: Ed25519, + val senderKey: Curve25519, + val deviceKeys: DeviceKeys, + val maxKeys: Int, + val olmAccount: Any, + ) + + data class RoomCryptoSession( + val creationTimestampUtc: Long, + val key: String, + val messageIndex: Int, + val accountCryptoSession: AccountCryptoSession, + val id: SessionId, + val outBound: Any, + ) + +} \ No newline at end of file diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/AccountCryptoUseCase.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/AccountCryptoUseCase.kt new file mode 100644 index 0000000..e979792 --- /dev/null +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/AccountCryptoUseCase.kt @@ -0,0 +1,21 @@ +package app.dapk.st.matrix.crypto.internal + +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.crypto.Olm +import app.dapk.st.matrix.device.DeviceService + +internal typealias FetchAccountCryptoUseCase = suspend () -> Olm.AccountCryptoSession + +internal class FetchAccountCryptoUseCaseImpl( + private val credentialsStore: CredentialsStore, + private val olm: Olm, + private val deviceService: DeviceService +) : FetchAccountCryptoUseCase { + + override suspend fun invoke(): Olm.AccountCryptoSession { + val credentials = credentialsStore.credentials()!! + return olm.ensureAccountCrypto(credentials) { accountCryptoSession -> + deviceService.uploadDeviceKeys(accountCryptoSession.deviceKeys) + } + } +} \ No newline at end of file diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/DefaultCryptoService.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/DefaultCryptoService.kt new file mode 100644 index 0000000..a0ecb89 --- /dev/null +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/DefaultCryptoService.kt @@ -0,0 +1,55 @@ +package app.dapk.st.matrix.crypto.internal + +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.crypto.Crypto +import app.dapk.st.matrix.crypto.CryptoService +import app.dapk.st.matrix.crypto.Verification +import kotlinx.coroutines.flow.Flow +import java.io.InputStream + +internal class DefaultCryptoService( + private val olmCrypto: OlmCrypto, + private val verificationHandler: VerificationHandler, + private val roomKeyImporter: RoomKeyImporter, + private val logger: MatrixLogger, +) : CryptoService { + override suspend fun encrypt(roomId: RoomId, credentials: DeviceCredentials, messageJson: JsonString): Crypto.EncryptionResult { + return olmCrypto.encryptMessage(roomId, credentials, messageJson) + } + + override suspend fun decrypt(encryptedPayload: EncryptedMessageContent): DecryptionResult { + return olmCrypto.decrypt(encryptedPayload).also { + logger.matrixLog("decrypted: $it") + } + } + + override suspend fun importRoomKeys(keys: List) { + olmCrypto.importRoomKeys(keys) + } + + override suspend fun maybeCreateMoreKeys(serverKeyCount: ServerKeyCount) { + olmCrypto.maybeCreateMoreKeys(serverKeyCount) + } + + override suspend fun updateOlmSession(userIds: List, syncToken: SyncToken?) { + olmCrypto.updateOlmSessions(userIds, syncToken) + } + + override suspend fun onVerificationEvent(event: Verification.Event) { + verificationHandler.onVerificationEvent(event) + } + + override fun verificationState(): Flow { + return verificationHandler.stateFlow + } + + override suspend fun verificationAction(verificationAction: Verification.Action) { + verificationHandler.onUserVerificationAction(verificationAction) + } + + override suspend fun InputStream.importRoomKeys(password: String): List { + return with(roomKeyImporter) { + importRoomKeys(password) { importRoomKeys(it) } + } + } +} diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/EncryptMessageWithMegolmUseCase.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/EncryptMessageWithMegolmUseCase.kt new file mode 100644 index 0000000..93a557d --- /dev/null +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/EncryptMessageWithMegolmUseCase.kt @@ -0,0 +1,33 @@ +package app.dapk.st.matrix.crypto.internal + +import app.dapk.st.matrix.common.AlgorithmName +import app.dapk.st.matrix.common.DeviceCredentials +import app.dapk.st.matrix.common.MatrixLogger +import app.dapk.st.matrix.common.crypto +import app.dapk.st.matrix.crypto.Crypto +import app.dapk.st.matrix.crypto.Olm + +private val ALGORITHM_MEGOLM = AlgorithmName("m.megolm.v1.aes-sha2") + +typealias EncryptMessageWithMegolmUseCase = suspend (DeviceCredentials, MessageToEncrypt) -> Crypto.EncryptionResult + +internal class EncryptMessageWithMegolmUseCaseImpl( + private val olm: Olm, + private val fetchMegolmSessionUseCase: FetchMegolmSessionUseCase, + private val logger: MatrixLogger, +) : EncryptMessageWithMegolmUseCase { + + override suspend fun invoke(credentials: DeviceCredentials, message: MessageToEncrypt): Crypto.EncryptionResult { + logger.crypto("encrypt") + val roomSession = fetchMegolmSessionUseCase.invoke(message.roomId) + val encryptedMessage = with(olm) { roomSession.encrypt(message.roomId, message.json) } + return Crypto.EncryptionResult( + ALGORITHM_MEGOLM, + senderKey = roomSession.accountCryptoSession.senderKey.value, + cipherText = encryptedMessage, + sessionId = roomSession.id, + deviceId = credentials.deviceId + ) + } + +} diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/FetchMegolmSessionUseCase.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/FetchMegolmSessionUseCase.kt new file mode 100644 index 0000000..7175119 --- /dev/null +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/FetchMegolmSessionUseCase.kt @@ -0,0 +1,48 @@ +package app.dapk.st.matrix.crypto.internal + +import app.dapk.st.matrix.common.MatrixLogger +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.crypto +import app.dapk.st.matrix.crypto.Olm +import app.dapk.st.matrix.crypto.RoomMembersProvider +import app.dapk.st.matrix.device.DeviceService +import app.dapk.st.matrix.device.internal.DeviceKeys + +internal typealias FetchMegolmSessionUseCase = suspend (RoomId) -> Olm.RoomCryptoSession + +internal class FetchMegolmSessionUseCaseImpl( + private val olm: Olm, + private val deviceService: DeviceService, + private val fetchAccountCryptoUseCase: FetchAccountCryptoUseCase, + private val roomMembersProvider: RoomMembersProvider, + private val registerOlmSessionUseCase: RegisterOlmSessionUseCase, + private val shareRoomKeyUseCase: ShareRoomKeyUseCase, + private val logger: MatrixLogger, +) : FetchMegolmSessionUseCase { + + override suspend fun invoke(roomId: RoomId): Olm.RoomCryptoSession { + logger.crypto("ensureOutboundMegolmSession") + val accountCryptoSession = fetchAccountCryptoUseCase.invoke() + return olm.ensureRoomCrypto(roomId, accountCryptoSession).also { it.maybeUpdateWithNewDevices(roomId, accountCryptoSession) } + } + + private suspend fun Olm.RoomCryptoSession.maybeUpdateWithNewDevices(roomId: RoomId, accountCryptoSession: Olm.AccountCryptoSession) { + val roomMemberIds = roomMembersProvider.userIdsForRoom(roomId) + val newDevices = deviceService.checkForNewDevices(accountCryptoSession.deviceKeys, roomMemberIds, this.id) + if (newDevices.isNotEmpty()) { + logger.crypto("found devices to update with megolm session") + val olmSessions = ensureOlmSessions(newDevices, accountCryptoSession) + shareRoomKeyUseCase.invoke(this, olmSessions, roomId) + } else { + logger.crypto("no devices to update with megolm") + } + } + + private suspend fun ensureOlmSessions(newDevices: List, accountCryptoSession: Olm.AccountCryptoSession): List { + return olm.olmSessions(newDevices, onMissing = { + logger.crypto("found missing olm sessions when creating megolm session ${it.map { "${it.userId}:${it.deviceId}" }}") + registerOlmSessionUseCase.invoke(it, accountCryptoSession) + }) + } + +} \ No newline at end of file diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/MessageToEncrypt.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/MessageToEncrypt.kt new file mode 100644 index 0000000..100cfd5 --- /dev/null +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/MessageToEncrypt.kt @@ -0,0 +1,6 @@ +package app.dapk.st.matrix.crypto.internal + +import app.dapk.st.matrix.common.JsonString +import app.dapk.st.matrix.common.RoomId + +data class MessageToEncrypt(val roomId: RoomId, val json: JsonString) \ No newline at end of file diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/OlmCrypto.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/OlmCrypto.kt new file mode 100644 index 0000000..52ec0dc --- /dev/null +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/OlmCrypto.kt @@ -0,0 +1,53 @@ +package app.dapk.st.matrix.crypto.internal + +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.crypto.Crypto +import app.dapk.st.matrix.crypto.Olm +import app.dapk.st.matrix.device.DeviceService + +internal class OlmCrypto( + private val olm: Olm, + private val deviceService: DeviceService, + private val logger: MatrixLogger, + private val registerOlmSessionUseCase: RegisterOlmSessionUseCase, + private val encryptMessageWithMegolmUseCase: EncryptMessageWithMegolmUseCase, + private val fetchAccountCryptoUseCase: FetchAccountCryptoUseCase, + private val maybeCreateAndUploadOneTimeKeysUseCase: MaybeCreateAndUploadOneTimeKeysUseCase +) { + + suspend fun importRoomKeys(keys: List) { + logger.crypto("import room keys : ${keys.size}") + olm.import(keys) + } + + suspend fun decrypt(payload: EncryptedMessageContent): DecryptionResult { + return when (payload) { + is EncryptedMessageContent.MegOlmV1 -> { + olm.decryptMegOlm(payload.sessionId, payload.cipherText) + } + is EncryptedMessageContent.OlmV1 -> { + val account = fetchAccountCryptoUseCase.invoke() + logger.crypto("decrypt olm: $payload") + payload.cipherText[account.senderKey]?.let { + olm.decryptOlm(account, payload.senderKey, it.type.toLong(), it.body) + } ?: DecryptionResult.Failed("Missing cipher for sender : ${account.senderKey}") + } + } + } + + suspend fun encryptMessage(roomId: RoomId, credentials: DeviceCredentials, messageJson: JsonString): Crypto.EncryptionResult { + val messageToEncrypt = MessageToEncrypt(roomId, messageJson) + return encryptMessageWithMegolmUseCase.invoke(credentials, messageToEncrypt) + } + + suspend fun updateOlmSessions(userId: List, syncToken: SyncToken?) { + logger.crypto("updating olm sessions for ${userId.map { it.value }}") + val account = fetchAccountCryptoUseCase.invoke() + val keys = deviceService.fetchDevices(userId, syncToken).filterNot { it.deviceId == account.deviceKeys.deviceId } + registerOlmSessionUseCase.invoke(keys, account) + } + + suspend fun maybeCreateMoreKeys(currentServerKeyCount: ServerKeyCount) { + maybeCreateAndUploadOneTimeKeysUseCase.invoke(currentServerKeyCount) + } +} diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/OneTimeKeyUploaderUseCase.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/OneTimeKeyUploaderUseCase.kt new file mode 100644 index 0000000..d51b6c9 --- /dev/null +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/OneTimeKeyUploaderUseCase.kt @@ -0,0 +1,42 @@ +package app.dapk.st.matrix.crypto.internal + +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.common.MatrixLogger +import app.dapk.st.matrix.common.ServerKeyCount +import app.dapk.st.matrix.common.crypto +import app.dapk.st.matrix.crypto.Olm +import app.dapk.st.matrix.device.DeviceService + +typealias MaybeCreateAndUploadOneTimeKeysUseCase = suspend (ServerKeyCount) -> Unit + +internal class MaybeCreateAndUploadOneTimeKeysUseCaseImpl( + private val fetchAccountCryptoUseCase: FetchAccountCryptoUseCase, + private val olm: Olm, + private val credentialsStore: CredentialsStore, + private val deviceService: DeviceService, + private val logger: MatrixLogger, +): MaybeCreateAndUploadOneTimeKeysUseCase { + + override suspend fun invoke(currentServerKeyCount: ServerKeyCount) { + val ensureCryptoAccount = fetchAccountCryptoUseCase.invoke() + val keysDiff = (ensureCryptoAccount.maxKeys / 2) - currentServerKeyCount.value + if (keysDiff > 0) { + logger.crypto("current otk: $currentServerKeyCount, creating: $keysDiff") + ensureCryptoAccount.createAndUploadOneTimeKeys(countToCreate = keysDiff + (ensureCryptoAccount.maxKeys / 4)) + } else { + logger.crypto("current otk: $currentServerKeyCount, not creating new keys") + } + } + + private suspend fun Olm.AccountCryptoSession.createAndUploadOneTimeKeys(countToCreate: Int) { + with(olm) { + generateOneTimeKeys(countToCreate, credentialsStore.credentials()!!) { + kotlin.runCatching { + deviceService.uploadOneTimeKeys(it) + }.onFailure { + logger.crypto("failed to uploading OTK ${it.message}") + } + } + } + } +} \ No newline at end of file diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/RegisterOlmSessionUseCase.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/RegisterOlmSessionUseCase.kt new file mode 100644 index 0000000..9e05b54 --- /dev/null +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/RegisterOlmSessionUseCase.kt @@ -0,0 +1,55 @@ +package app.dapk.st.matrix.crypto.internal + +import app.dapk.st.matrix.common.AlgorithmName +import app.dapk.st.matrix.common.DeviceId +import app.dapk.st.matrix.common.MatrixLogger +import app.dapk.st.matrix.common.crypto +import app.dapk.st.matrix.crypto.Olm +import app.dapk.st.matrix.device.DeviceService +import app.dapk.st.matrix.device.DeviceService.KeyClaim +import app.dapk.st.matrix.device.internal.ClaimKeysResponse +import app.dapk.st.matrix.device.internal.DeviceKeys +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +private val KEY_SIGNED_CURVE_25519_TYPE = AlgorithmName("signed_curve25519") + +internal typealias RegisterOlmSessionUseCase = suspend (List, Olm.AccountCryptoSession) -> List + +internal class RegisterOlmSessionUseCaseImpl( + private val olm: Olm, + private val deviceService: DeviceService, + private val logger: MatrixLogger, +) : RegisterOlmSessionUseCase { + + override suspend fun invoke(deviceKeys: List, olmAccount: Olm.AccountCryptoSession): List { + logger.crypto("registering olm session for devices") + val devicesByDeviceId = deviceKeys.associateBy { it.deviceId } + val keyClaims = deviceKeys.map { KeyClaim(it.userId, it.deviceId, algorithmName = KEY_SIGNED_CURVE_25519_TYPE) } + logger.crypto("attempt claim: $keyClaims") + return deviceService.claimKeys(keyClaims) + .toOlmRequests(devicesByDeviceId) + .also { logger.crypto("claim result: $it") } + .map { olm.ensureDeviceCrypto(it, olmAccount) } + } + + private fun ClaimKeysResponse.toOlmRequests(devices: Map) = this.oneTimeKeys.map { (userId, devicesToKeys) -> + devicesToKeys.mapNotNull { (deviceId, payload) -> + when (payload) { + is JsonObject -> { + val key = when (val content = payload.values.first()) { + is JsonObject -> (content["key"] as JsonPrimitive).content + else -> throw RuntimeException("Missing key") + } + val identity = devices.identity(deviceId) + val fingerprint = devices.fingerprint(deviceId) + Olm.OlmSessionInput(oneTimeKey = key, identity = identity, deviceId, userId, fingerprint) + } + else -> null + } + } + }.flatten() +} + +private fun Map.identity(deviceId: DeviceId) = this[deviceId]!!.identity() +private fun Map.fingerprint(deviceId: DeviceId) = this[deviceId]!!.fingerprint() \ No newline at end of file diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/RoomKeyImporter.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/RoomKeyImporter.kt new file mode 100644 index 0000000..3904a93 --- /dev/null +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/RoomKeyImporter.kt @@ -0,0 +1,178 @@ +package app.dapk.st.matrix.crypto.internal + +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.withIoContext +import app.dapk.st.matrix.common.AlgorithmName +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.SessionId +import app.dapk.st.matrix.common.SharedRoomKey +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.IOException +import java.io.InputStream +import java.nio.charset.Charset +import java.util.* +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.experimental.xor + +private const val HEADER_LINE = "-----BEGIN MEGOLM SESSION DATA-----" +private const val TRAILER_LINE = "-----END MEGOLM SESSION DATA-----" +private val importJson = Json { ignoreUnknownKeys = true } + +class RoomKeyImporter(private val dispatchers: CoroutineDispatchers) { + + suspend fun InputStream.importRoomKeys(password: String, onChunk: suspend (List) -> Unit): List { + return dispatchers.withIoContext { + val decoder = Base64.getDecoder() + val decryptCipher = Cipher.getInstance("AES/CTR/NoPadding") + var jsonSegment = "" + + fun Sequence.accumulateJson() = this.mapNotNull { + val withLatest = jsonSegment + it + try { + when (val objectRange = withLatest.findClosingIndex()) { + null -> { + jsonSegment = withLatest + null + } + else -> { + val string = withLatest.substring(objectRange) + importJson.decodeFromString(ElementMegolmExportObject.serializer(), string).also { + jsonSegment = withLatest.replace(string, "").removePrefix(",") + } + } + } + } catch (error: Throwable) { + jsonSegment = withLatest + null + } + } + + this@importRoomKeys.bufferedReader().use { + val roomIds = mutableSetOf() + it.useLines { sequence -> + sequence + .filterNot { it == HEADER_LINE || it == TRAILER_LINE || it.isEmpty() } + .chunked(2) + .withIndex() + .map { (index, it) -> + val line = it.joinToString(separator = "").replace("\n", "") + val toByteArray = decoder.decode(line) + if (index == 0) { + decryptCipher.initialize(toByteArray, password) + toByteArray.copyOfRange(37, toByteArray.size).decrypt(decryptCipher).also { + if (!it.startsWith("[{")) { + throw IllegalArgumentException("Unable to decrypt, assumed invalid password") + } + } + } else { + toByteArray.decrypt(decryptCipher) + } + } + .accumulateJson() + .map { decoded -> + roomIds.add(decoded.roomId) + SharedRoomKey( + decoded.algorithmName, + decoded.roomId, + decoded.sessionId, + decoded.sessionKey, + isExported = true, + ) + } + .chunked(50) + .forEach { onChunk(it) } + } + roomIds.toList().ifEmpty { + throw IOException("Found no rooms to import in the file") + } + } + } + } + + private fun Cipher.initialize(payload: ByteArray, passphrase: String) { + val salt = payload.copyOfRange(1, 1 + 16) + val iv = payload.copyOfRange(17, 17 + 16) + val iterations = (payload[33].toUnsignedInt() shl 24) or + (payload[34].toUnsignedInt() shl 16) or + (payload[35].toUnsignedInt() shl 8) or + payload[36].toUnsignedInt() + val deriveKey = deriveKeys(salt, iterations, passphrase) + val secretKeySpec = SecretKeySpec(deriveKey.getAesKey(), "AES") + val ivParameterSpec = IvParameterSpec(iv) + this.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec) + } + + private fun ByteArray.decrypt(cipher: Cipher): String { + return cipher.update(this).toString(Charset.defaultCharset()) + } + + private fun ByteArray.getAesKey() = this.copyOfRange(0, 32) + + private fun deriveKeys(salt: ByteArray, iterations: Int, password: String): ByteArray { + val prf = Mac.getInstance("HmacSHA512") + prf.init(SecretKeySpec(password.toByteArray(Charsets.UTF_8), "HmacSHA512")) + + // 512 bits key length + val key = ByteArray(64) + val uc = ByteArray(64) + + // U1 = PRF(Password, Salt || INT_32_BE(i)) + prf.update(salt) + val int32BE = ByteArray(4) { 0.toByte() } + int32BE[3] = 1.toByte() + prf.update(int32BE) + prf.doFinal(uc, 0) + + // copy to the key + System.arraycopy(uc, 0, key, 0, uc.size) + + for (index in 2..iterations) { + // Uc = PRF(Password, Uc-1) + prf.update(uc) + prf.doFinal(uc, 0) + + // F(Password, Salt, c, i) = U1 ^ U2 ^ ... ^ Uc + for (byteIndex in uc.indices) { + key[byteIndex] = key[byteIndex] xor uc[byteIndex] + } + } + return key + } +} + +private fun Byte.toUnsignedInt() = toInt() and 0xff + +private fun String.findClosingIndex(): IntRange? { + var opens = 0 + var openIndex = -1 + this.forEachIndexed { index, c -> + when { + c == '{' -> { + if (opens == 0) { + openIndex = index + } + opens++ + } + c == '}' -> { + opens-- + if (opens == 0) { + return IntRange(openIndex, index) + } + } + } + } + return null +} + +@Serializable +private data class ElementMegolmExportObject( + @SerialName("room_id") val roomId: RoomId, + @SerialName("session_key") val sessionKey: String, + @SerialName("session_id") val sessionId: SessionId, + @SerialName("algorithm") val algorithmName: AlgorithmName, +) diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/ShareRoomKeyUseCase.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/ShareRoomKeyUseCase.kt new file mode 100644 index 0000000..7627a0d --- /dev/null +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/ShareRoomKeyUseCase.kt @@ -0,0 +1,65 @@ +package app.dapk.st.matrix.crypto.internal + +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.common.extensions.toJsonString +import app.dapk.st.matrix.crypto.Olm +import app.dapk.st.matrix.device.DeviceService +import app.dapk.st.matrix.device.ToDevicePayload + +private val ALGORITHM_OLM = AlgorithmName("m.olm.v1.curve25519-aes-sha2") +private val ALGORITHM_MEGOLM = AlgorithmName("m.megolm.v1.aes-sha2") + +internal typealias ShareRoomKeyUseCase = suspend (room: Olm.RoomCryptoSession, List, RoomId) -> Unit + +internal class ShareRoomKeyUseCaseImpl( + private val credentialsStore: CredentialsStore, + private val deviceService: DeviceService, + private val logger: MatrixLogger, + private val olm: Olm, +) : ShareRoomKeyUseCase { + + override suspend fun invoke(roomSessionToShare: Olm.RoomCryptoSession, olmSessionsToEncryptMessage: List, roomId: RoomId) { + val credentials = credentialsStore.credentials()!! + logger.crypto("creating megolm payloads for $roomId: ${olmSessionsToEncryptMessage.map { it.userId to it.deviceId }}") + + val toMessages = olmSessionsToEncryptMessage.map { + val payload = mapOf( + "type" to "m.room_key", + "content" to mapOf( + "algorithm" to ALGORITHM_MEGOLM.value, + "room_id" to roomId.value, + "session_id" to roomSessionToShare.id.value, + "session_key" to roomSessionToShare.key, + "chain_index" to roomSessionToShare.messageIndex, + ), + "sender" to credentials.userId.value, + "sender_device" to credentials.deviceId.value, + "keys" to mapOf( + "ed25519" to roomSessionToShare.accountCryptoSession.fingerprint.value + ), + "recipient" to it.userId.value, + "recipient_keys" to mapOf( + "ed25519" to it.fingerprint.value + ) + ) + + val result = with(olm) { it.encrypt(payload.toJsonString()) } + DeviceService.ToDeviceMessage( + senderId = it.userId, + deviceId = it.deviceId, + ToDevicePayload.EncryptedToDevicePayload( + algorithmName = ALGORITHM_OLM, + senderKey = roomSessionToShare.accountCryptoSession.senderKey, + cipherText = mapOf( + it.identity to ToDevicePayload.EncryptedToDevicePayload.Inner( + cipherText = result.cipherText, + type = result.type, + ) + ) + ), + ) + } + logger.crypto("sharing keys") + deviceService.sendRoomKeyToDevice(roomSessionToShare.id, toMessages) + } +} \ No newline at end of file diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/VerificationHandler.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/VerificationHandler.kt new file mode 100644 index 0000000..035709f --- /dev/null +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/VerificationHandler.kt @@ -0,0 +1,212 @@ +package app.dapk.st.matrix.crypto.internal + +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.crypto.Olm +import app.dapk.st.matrix.crypto.Verification +import app.dapk.st.matrix.device.DeviceService +import app.dapk.st.matrix.device.ToDevicePayload +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.serialization.json.Json +import java.util.* + +internal class VerificationHandler( + private val deviceService: DeviceService, + private val credentialsStore: CredentialsStore, + private val logger: MatrixLogger, + private val jsonCanonicalizer: JsonCanonicalizer, + private val olm: Olm, +) { + + data class VerificationTransaction( + val userId: UserId, + val deviceId: DeviceId, + val transactionId: String, + ) + + val stateFlow = MutableStateFlow(Verification.State.Idle) + + var verificationTransaction = VerificationTransaction(UserId(""), DeviceId(""), "") + var sasSession: Olm.SasSession? = null + var requesterStartPayload: ToDevicePayload.VerificationStart? = null + + suspend fun onUserVerificationAction(action: Verification.Action) { + when (action) { + is Verification.Action.Request -> requestVerification(action.userId, action.deviceId) + Verification.Action.SecureAccept -> { + stateFlow.emit(Verification.State.ReadySent) + } + Verification.Action.InsecureAccept -> { + sendToDevice(ToDevicePayload.VerificationDone(verificationTransaction.transactionId)) + + stateFlow.emit(Verification.State.WaitingForDoneConfirmation) + } + Verification.Action.AcknowledgeMatch -> { + val credentials = credentialsStore.credentials()!! + val mac = sasSession!!.calculateMac( + credentials.userId, + credentials.deviceId, + verificationTransaction.userId, + verificationTransaction.deviceId, + verificationTransaction.transactionId + ) + + sendToDevice( + ToDevicePayload.VerificationMac( + verificationTransaction.transactionId, + mac.keys, + mac.mac + ) + ) + } + } + } + + private suspend fun requestVerification(userId: UserId, deviceId: DeviceId) { + val transactionId = UUID.randomUUID().toString() + + verificationTransaction = VerificationTransaction(userId, deviceId, transactionId) + + sendToDevice( + ToDevicePayload.VerificationRequest( + fromDevice = credentialsStore.credentials()!!.deviceId, + methods = listOf("m.sas.v1"), + transactionId = transactionId, + timestampPosix = System.currentTimeMillis() + ) + ) + } + + suspend fun onVerificationEvent(event: Verification.Event) { + logger.matrixLog(MatrixLogTag.VERIFICATION, "handling event: $event") + when (event) { + is Verification.Event.Requested -> { + stateFlow.emit(Verification.State.ReadySent) + + verificationTransaction = VerificationTransaction( + event.userId, event.deviceId, event.transactionId + ) + + sendToDevice( + ToDevicePayload.VerificationReady( + fromDevice = credentialsStore.credentials()!!.deviceId, + methods = listOf("m.sas.v1"), + event.transactionId, + ) + ) + } + is Verification.Event.Ready -> { + val startPayload = ToDevicePayload.VerificationStart( + fromDevice = verificationTransaction.deviceId, + method = event.methods.first { it == "m.sas.v1" }, + protocols = listOf("curve25519-hkdf-sha256"), + hashes = listOf("sha256"), + codes = listOf("hkdf-hmac-sha256"), + short = listOf("emoji"), + event.transactionId, + ) + requesterStartPayload = startPayload + sendToDevice(startPayload) + } + is Verification.Event.Started -> { + val self = credentialsStore.credentials()!!.userId.value + val shouldSendStart = listOf(verificationTransaction.userId.value, self).minOrNull() == self + + + val startPayload = ToDevicePayload.VerificationStart( + fromDevice = verificationTransaction.deviceId, + method = event.method, + protocols = event.protocols, + hashes = event.hashes, + codes = event.codes, + short = event.short, + event.transactionId, + ) + + val startJson = startPayload.toCanonicalJson() + + logger.matrixLog(MatrixLogTag.VERIFICATION, "startJson: $startJson") + + sasSession = olm.sasSession(credentialsStore.credentials()!!) + + val commitment = sasSession!!.generateCommitment(hash = "sha256", startJson) + + sendToDevice( + ToDevicePayload.VerificationAccept( + transactionId = event.transactionId, + fromDevice = credentialsStore.credentials()!!.deviceId, + method = event.method, + protocol = "curve25519-hkdf-sha256", + hash = "sha256", + code = "hkdf-hmac-sha256", + short = listOf("emoji", "decimal"), + commitment = commitment, + ) + ) + + } + + is Verification.Event.Accepted -> { + sasSession = olm.sasSession(credentialsStore.credentials()!!) + sendToDevice( + ToDevicePayload.VerificationKey( + verificationTransaction.transactionId, + key = sasSession!!.publicKey() + ) + ) + } + is Verification.Event.Key -> { + sasSession!!.setTheirPublicKey(event.key) + sendToDevice( + ToDevicePayload.VerificationKey( + transactionId = event.transactionId, + key = sasSession!!.publicKey() + ) + ) + stateFlow.emit(Verification.State.WaitingForMatchConfirmation) + } + is Verification.Event.Mac -> { +// val credentials = credentialsStore.credentials()!! +// +// val mac = sasSession!!.calculateMac( +// credentials.userId, credentials.deviceId, event.userId, verificationTransaction.deviceId, event.transactionId +// ) +// +// sendToDevice( +// ToDevicePayload.VerificationMac( +// event.transactionId, +// mac.keys, +// mac.mac +// ) +// ) + // TODO verify mac? + sendToDevice(ToDevicePayload.VerificationDone(verificationTransaction.transactionId)) + stateFlow.emit(Verification.State.Done) + } + } + } + + private fun ToDevicePayload.VerificationStart.toCanonicalJson() = jsonCanonicalizer.canonicalize( + JsonString(Json.encodeToString(ToDevicePayload.VerificationStart.serializer(), this)) + ) + + private suspend fun sendToDevice(payload: ToDevicePayload.VerificationPayload) { + logger.matrixLog(MatrixLogTag.VERIFICATION, "sending ${payload::class.java}") + + deviceService.sendToDevice( + when (payload) { + is ToDevicePayload.VerificationRequest -> EventType.VERIFICATION_REQUEST + is ToDevicePayload.VerificationStart -> EventType.VERIFICATION_START + is ToDevicePayload.VerificationDone -> EventType.VERIFICATION_DONE + is ToDevicePayload.VerificationReady -> EventType.VERIFICATION_READY + is ToDevicePayload.VerificationAccept -> EventType.VERIFICATION_ACCEPT + is ToDevicePayload.VerificationMac -> EventType.VERIFICATION_MAC + is ToDevicePayload.VerificationKey -> EventType.VERIFICATION_KEY + }, + verificationTransaction.transactionId, + verificationTransaction.userId, + verificationTransaction.deviceId, + payload as ToDevicePayload + ) + } + +} \ No newline at end of file diff --git a/matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/EncryptMegolmUseCaseTest.kt b/matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/EncryptMegolmUseCaseTest.kt new file mode 100644 index 0000000..e946332 --- /dev/null +++ b/matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/EncryptMegolmUseCaseTest.kt @@ -0,0 +1,59 @@ +package app.dapk.st.matrix.crypto.internal + +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.crypto.Crypto +import fake.FakeMatrixLogger +import fake.FakeOlm +import fixture.* +import internalfake.FakeFetchMegolmSessionUseCase +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +private val A_ROOM_ID = aRoomId() +private val A_MESSAGE_TO_ENCRYPT = aMessageToEncrypt(roomId = A_ROOM_ID) +private val AN_ENCRYPTION_CIPHER_RESULT = aCipherText() +private val A_DEVICE_CREDENTIALS = aDeviceCredentials() +private val AN_ACCOUNT_CRYPTO_SESSION = anAccountCryptoSession() +private val A_ROOM_CRYPTO_SESSION = aRoomCryptoSession(accountCryptoSession = AN_ACCOUNT_CRYPTO_SESSION) + +class EncryptMegolmUseCaseTest { + + private val fetchMegolmSessionUseCase = FakeFetchMegolmSessionUseCase() + private val fakeOlm = FakeOlm() + + private val encryptMegolmUseCase = EncryptMessageWithMegolmUseCaseImpl( + fakeOlm, + fetchMegolmSessionUseCase, + FakeMatrixLogger(), + ) + + @Test + fun `given a room crypto session then encrypts messages with megolm`() = runTest { + fetchMegolmSessionUseCase.givenSessionForRoom(A_ROOM_ID, A_ROOM_CRYPTO_SESSION) + fakeOlm.givenEncrypts(A_ROOM_CRYPTO_SESSION, A_MESSAGE_TO_ENCRYPT.roomId, A_MESSAGE_TO_ENCRYPT.json, AN_ENCRYPTION_CIPHER_RESULT) + + val result = encryptMegolmUseCase.invoke(aDeviceCredentials(), A_MESSAGE_TO_ENCRYPT) + + result shouldBeEqualTo anEncryptionResult( + AlgorithmName("m.megolm.v1.aes-sha2"), + senderKey = AN_ACCOUNT_CRYPTO_SESSION.senderKey.value, + cipherText = AN_ENCRYPTION_CIPHER_RESULT, + sessionId = A_ROOM_CRYPTO_SESSION.id, + deviceId = A_DEVICE_CREDENTIALS.deviceId + ) + } +} + +fun aMessageToEncrypt( + roomId: RoomId = aRoomId(), + messageJson: JsonString = aJsonString() +) = MessageToEncrypt(roomId, messageJson) + +fun anEncryptionResult( + algorithmName: AlgorithmName = anAlgorithmName(), + senderKey: String = "a-sender-key", + cipherText: CipherText = aCipherText(), + sessionId: SessionId = aSessionId(), + deviceId: DeviceId = aDeviceId(), +) = Crypto.EncryptionResult(algorithmName, senderKey, cipherText, sessionId, deviceId) \ No newline at end of file diff --git a/matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/FetchAccountCryptoUseCaseTest.kt b/matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/FetchAccountCryptoUseCaseTest.kt new file mode 100644 index 0000000..8bcf4be --- /dev/null +++ b/matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/FetchAccountCryptoUseCaseTest.kt @@ -0,0 +1,48 @@ +package app.dapk.st.matrix.crypto.internal + +import fake.FakeCredentialsStore +import fake.FakeDeviceService +import fake.FakeOlm +import fixture.aUserCredentials +import fixture.anAccountCryptoSession +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import test.expect + +private val AN_ACCOUNT_CRYPTO_SESSION = anAccountCryptoSession() +private val A_USER_CREDENTIALS = aUserCredentials() + +class FetchAccountCryptoUseCaseTest { + + private val credentialsStore = FakeCredentialsStore() + private val olm = FakeOlm() + private val deviceService = FakeDeviceService() + + private val fetchAccountCryptoUseCase = FetchAccountCryptoUseCaseImpl( + credentialsStore, + olm, + deviceService, + ) + + @Test + fun `when creating an account crypto session then also uploads device keys`() = runTest { + credentialsStore.givenCredentials().returns(A_USER_CREDENTIALS) + olm.givenCreatesAccount(A_USER_CREDENTIALS).returns(AN_ACCOUNT_CRYPTO_SESSION) + deviceService.expect { it.uploadDeviceKeys(AN_ACCOUNT_CRYPTO_SESSION.deviceKeys) } + + val result = fetchAccountCryptoUseCase.invoke() + + result shouldBeEqualTo AN_ACCOUNT_CRYPTO_SESSION + } + + @Test + fun `when fetching an existing crypto session then returns`() = runTest { + credentialsStore.givenCredentials().returns(A_USER_CREDENTIALS) + olm.givenAccount(A_USER_CREDENTIALS).returns(AN_ACCOUNT_CRYPTO_SESSION) + + val result = fetchAccountCryptoUseCase.invoke() + + result shouldBeEqualTo AN_ACCOUNT_CRYPTO_SESSION + } +} diff --git a/matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/FetchMegolmSessionUseCaseTest.kt b/matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/FetchMegolmSessionUseCaseTest.kt new file mode 100644 index 0000000..6ebcf67 --- /dev/null +++ b/matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/FetchMegolmSessionUseCaseTest.kt @@ -0,0 +1,63 @@ +package app.dapk.st.matrix.crypto.internal + +import fake.FakeDeviceService +import fake.FakeMatrixLogger +import fake.FakeOlm +import fixture.* +import internalfake.FakeFetchAccountCryptoUseCase +import internalfake.FakeRegisterOlmSessionUseCase +import internalfake.FakeShareRoomKeyUseCase +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +private val A_ROOM_ID = aRoomId() +private val AN_ACCOUNT_CRYPTO_SESSION = anAccountCryptoSession() +private val A_ROOM_CRYPTO_SESSION = aRoomCryptoSession() +private val USERS_IN_ROOM = listOf(aUserId()) +private val NEW_DEVICES = listOf(aDeviceKeys()) +private val MISSING_OLM_SESSIONS = listOf(aDeviceCryptoSession()) + +class FetchMegolmSessionUseCaseTest { + + private val fakeOlm = FakeOlm() + private val deviceService = FakeDeviceService() + private val roomMembersProvider = FakeRoomMembersProvider() + private val fakeRegisterOlmSessionUseCase = FakeRegisterOlmSessionUseCase() + private val fakeShareRoomKeyUseCase = FakeShareRoomKeyUseCase() + + private val fetchMegolmSessionUseCase = FetchMegolmSessionUseCaseImpl( + fakeOlm, + deviceService, + FakeFetchAccountCryptoUseCase().also { it.givenAccount(AN_ACCOUNT_CRYPTO_SESSION) }, + roomMembersProvider, + fakeRegisterOlmSessionUseCase, + fakeShareRoomKeyUseCase, + FakeMatrixLogger(), + ) + + @Test + fun `given new devices with missing olm sessions when fetching megolm session then creates olm session, megolm session and shares megolm key`() = runTest { + fakeOlm.givenRoomCrypto(A_ROOM_ID, AN_ACCOUNT_CRYPTO_SESSION).returns(A_ROOM_CRYPTO_SESSION) + roomMembersProvider.givenUserIdsForRoom(A_ROOM_ID).returns(USERS_IN_ROOM) + deviceService.givenNewDevices(AN_ACCOUNT_CRYPTO_SESSION.deviceKeys, USERS_IN_ROOM, A_ROOM_CRYPTO_SESSION.id).returns(NEW_DEVICES) + fakeOlm.givenMissingOlmSessions(NEW_DEVICES).returns(MISSING_OLM_SESSIONS) + fakeRegisterOlmSessionUseCase.givenRegistersSessions(NEW_DEVICES, AN_ACCOUNT_CRYPTO_SESSION).returns(MISSING_OLM_SESSIONS) + fakeShareRoomKeyUseCase.expect(A_ROOM_CRYPTO_SESSION, MISSING_OLM_SESSIONS, A_ROOM_ID) + + val result = fetchMegolmSessionUseCase.invoke(aRoomId()) + + result shouldBeEqualTo A_ROOM_CRYPTO_SESSION + } + + @Test + fun `given no new devices when fetching megolm session then returns existing megolm session`() = runTest { + fakeOlm.givenRoomCrypto(A_ROOM_ID, AN_ACCOUNT_CRYPTO_SESSION).returns(A_ROOM_CRYPTO_SESSION) + roomMembersProvider.givenUserIdsForRoom(A_ROOM_ID).returns(USERS_IN_ROOM) + deviceService.givenNewDevices(AN_ACCOUNT_CRYPTO_SESSION.deviceKeys, USERS_IN_ROOM, A_ROOM_CRYPTO_SESSION.id).returns(emptyList()) + + val result = fetchMegolmSessionUseCase.invoke(aRoomId()) + + result shouldBeEqualTo A_ROOM_CRYPTO_SESSION + } +} \ No newline at end of file diff --git a/matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/MaybeCreateAndUploadOneTimeKeysUseCaseTest.kt b/matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/MaybeCreateAndUploadOneTimeKeysUseCaseTest.kt new file mode 100644 index 0000000..dd9b9f5 --- /dev/null +++ b/matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/MaybeCreateAndUploadOneTimeKeysUseCaseTest.kt @@ -0,0 +1,65 @@ +package app.dapk.st.matrix.crypto.internal + +import app.dapk.st.matrix.common.ServerKeyCount +import app.dapk.st.matrix.device.DeviceService +import fake.FakeCredentialsStore +import fake.FakeDeviceService +import fake.FakeMatrixLogger +import fake.FakeOlm +import fixture.aUserCredentials +import fixture.anAccountCryptoSession +import internalfake.FakeFetchAccountCryptoUseCase +import kotlinx.coroutines.test.runTest +import org.junit.Test +import test.expect + +private const val MAX_KEYS = 100 +private val AN_ACCOUNT_CRYPTO_SESSION = anAccountCryptoSession(maxKeys = MAX_KEYS) +private val A_USER_CREDENTIALS = aUserCredentials() +private val GENERATED_ONE_TIME_KEYS = DeviceService.OneTimeKeys(listOf()) + +class MaybeCreateAndUploadOneTimeKeysUseCaseTest { + + private val fakeDeviceService = FakeDeviceService() + private val fakeOlm = FakeOlm() + private val fakeCredentialsStore = FakeCredentialsStore().also { + it.givenCredentials().returns(A_USER_CREDENTIALS) + } + + private val maybeCreateAndUploadOneTimeKeysUseCase = MaybeCreateAndUploadOneTimeKeysUseCaseImpl( + FakeFetchAccountCryptoUseCase().also { it.givenAccount(AN_ACCOUNT_CRYPTO_SESSION) }, + fakeOlm, + fakeCredentialsStore, + fakeDeviceService, + FakeMatrixLogger(), + ) + + @Test + fun `given more keys than the current max then does nothing`() = runTest { + val moreThanHalfOfMax = ServerKeyCount((MAX_KEYS / 2) + 1) + + maybeCreateAndUploadOneTimeKeysUseCase.invoke(moreThanHalfOfMax) + + fakeDeviceService.verifyDidntUploadOneTimeKeys() + } + + @Test + fun `given 0 current keys than generates and uploads 75 percent of the max key capacity`() = runTest { + fakeDeviceService.expect { it.uploadOneTimeKeys(GENERATED_ONE_TIME_KEYS) } + val keysToGenerate = (MAX_KEYS * 0.75f).toInt() + fakeOlm.givenGeneratesOneTimeKeys(AN_ACCOUNT_CRYPTO_SESSION, keysToGenerate, A_USER_CREDENTIALS).returns(GENERATED_ONE_TIME_KEYS) + + maybeCreateAndUploadOneTimeKeysUseCase.invoke(ServerKeyCount(0)) + } + + @Test + fun `given less than half of max current keys than generates and uploads 25 percent plus delta from half of the max key capacity`() = runTest { + val deltaFromHalf = 5 + val lessThanHalfOfMax = ServerKeyCount((MAX_KEYS / 2) - deltaFromHalf) + val keysToGenerate = (MAX_KEYS * 0.25).toInt() + deltaFromHalf + fakeDeviceService.expect { it.uploadOneTimeKeys(GENERATED_ONE_TIME_KEYS) } + fakeOlm.givenGeneratesOneTimeKeys(AN_ACCOUNT_CRYPTO_SESSION, keysToGenerate, A_USER_CREDENTIALS).returns(GENERATED_ONE_TIME_KEYS) + + maybeCreateAndUploadOneTimeKeysUseCase.invoke(lessThanHalfOfMax) + } +} \ No newline at end of file diff --git a/matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/RegisterOlmSessionUseCaseTest.kt b/matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/RegisterOlmSessionUseCaseTest.kt new file mode 100644 index 0000000..d1475e4 --- /dev/null +++ b/matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/RegisterOlmSessionUseCaseTest.kt @@ -0,0 +1,89 @@ +package app.dapk.st.matrix.crypto.internal + +import app.dapk.st.matrix.common.AlgorithmName +import app.dapk.st.matrix.common.DeviceId +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.crypto.Olm +import fake.FakeDeviceService +import fake.FakeMatrixLogger +import fake.FakeOlm +import fixture.* +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.* +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +private val KEY_SIGNED_CURVE_25519_TYPE = AlgorithmName("signed_curve25519") + +private const val A_CLAIM_KEY_RESPONSE = "a-claimed-key" +private const val A_DEVICE_IDENTITY = "a-claimed-signature" +private const val A_DEVICE_FINGERPRINT = "a-claimed-fingerprint" +private val A_DEVICE_ID_TO_REGISTER = aDeviceId("device-id-to-register") +private val A_USER_ID_TO_REGISTER = aUserId("user-id-to-register") +private val A_DEVICE_KEYS_TO_REGISTER = aDeviceKeys( + userId = A_USER_ID_TO_REGISTER, + deviceId = A_DEVICE_ID_TO_REGISTER, + keys = mapOf( + "ed25519:${A_DEVICE_ID_TO_REGISTER.value}" to A_DEVICE_FINGERPRINT, + "curve25519:${A_DEVICE_ID_TO_REGISTER.value}" to A_DEVICE_IDENTITY, + ) +) +private val A_DEVICE_CRYPTO_SESSION = aDeviceCryptoSession(identity = aCurve25519("an-olm-identity")) +private val A_KEY_CLAIM = aKeyClaim( + userId = A_USER_ID_TO_REGISTER, + deviceId = A_DEVICE_ID_TO_REGISTER, + algorithmName = KEY_SIGNED_CURVE_25519_TYPE +) +private val AN_ACCOUNT_CRYPTO_SESSION = anAccountCryptoSession() + +class RegisterOlmSessionUseCaseTest { + + private val fakeOlm = FakeOlm() + private val fakeDeviceService = FakeDeviceService() + + private val registerOlmSessionUseCase = RegisterOlmSessionUseCaseImpl( + fakeOlm, + fakeDeviceService, + FakeMatrixLogger() + ) + + @Test + fun `given keys when registering then claims keys and creates olm session`() = runTest { + fakeDeviceService.givenClaimsKeys(listOf(A_KEY_CLAIM)).returns(claimKeysResponse(A_USER_ID_TO_REGISTER, A_DEVICE_ID_TO_REGISTER)) + val expectedInput = expectOlmSessionCreationInput() + fakeOlm.givenDeviceCrypto(expectedInput, AN_ACCOUNT_CRYPTO_SESSION).returns(A_DEVICE_CRYPTO_SESSION) + + val result = registerOlmSessionUseCase.invoke(listOf(A_DEVICE_KEYS_TO_REGISTER), AN_ACCOUNT_CRYPTO_SESSION) + + result shouldBeEqualTo listOf(A_DEVICE_CRYPTO_SESSION) + } + + private fun expectOlmSessionCreationInput() = Olm.OlmSessionInput( + A_CLAIM_KEY_RESPONSE, + A_DEVICE_KEYS_TO_REGISTER.identity(), + A_DEVICE_ID_TO_REGISTER, + A_USER_ID_TO_REGISTER, + A_DEVICE_KEYS_TO_REGISTER.fingerprint() + ) + + private fun claimKeysResponse(userId: UserId, deviceId: DeviceId) = aClaimKeysResponse(oneTimeKeys = mapOf(userId to mapOf(deviceId to jsonElement()))) + + private fun jsonElement() = Json.encodeToJsonElement( + JsonObject( + mapOf( + "signed_curve25519:AAAAHg" to JsonObject( + mapOf( + "key" to JsonPrimitive(A_CLAIM_KEY_RESPONSE), + "signatures" to JsonObject( + mapOf( + A_USER_ID_TO_REGISTER.value to JsonObject( + mapOf("ed25519:${A_DEVICE_ID_TO_REGISTER.value}" to JsonPrimitive(A_DEVICE_FINGERPRINT)) + ) + ) + ) + ) + ) + ) + ) + ) +} diff --git a/matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/ShareRoomKeyUseCaseTest.kt b/matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/ShareRoomKeyUseCaseTest.kt new file mode 100644 index 0000000..9f48d7d --- /dev/null +++ b/matrix/services/crypto/src/test/kotlin/app/dapk/st/matrix/crypto/internal/ShareRoomKeyUseCaseTest.kt @@ -0,0 +1,85 @@ +package app.dapk.st.matrix.crypto.internal + +import app.dapk.st.matrix.common.AlgorithmName +import app.dapk.st.matrix.common.SessionId +import app.dapk.st.matrix.common.extensions.toJsonString +import app.dapk.st.matrix.crypto.Olm +import app.dapk.st.matrix.device.DeviceService +import app.dapk.st.matrix.device.ToDevicePayload +import fake.FakeCredentialsStore +import fake.FakeDeviceService +import fake.FakeMatrixLogger +import fake.FakeOlm +import fixture.* +import io.mockk.coVerify +import kotlinx.coroutines.test.runTest +import org.junit.Test +import test.expect + +private val A_USER_CREDENTIALS = aUserCredentials() +private val A_ROOM_CRYPTO_SESSION = aRoomCryptoSession() +private val A_ROOM_ID = aRoomId() +private val ALGORITHM_MEGOLM = AlgorithmName("m.megolm.v1.aes-sha2") +private val ALGORITHM_OLM = AlgorithmName("m.olm.v1.curve25519-aes-sha2") +private val AN_OLM_ENCRPYTION_RESULT = Olm.EncryptionResult(aCipherText(), type = 1) + +class ShareRoomKeyUseCaseTest { + + private val fakeDeviceService = FakeDeviceService() + private val fakeOlm = FakeOlm() + + private val shareRoomKeyUseCase = ShareRoomKeyUseCaseImpl( + FakeCredentialsStore().also { it.givenCredentials().returns(A_USER_CREDENTIALS) }, + fakeDeviceService, + FakeMatrixLogger(), + fakeOlm + ) + + @Test + fun `when sharing room key then encrypts with olm session and sends to device`() = runTest { + fakeDeviceService.expect { it.sendRoomKeyToDevice(SessionId(any()), any()) } + val olmSessionToEncryptWith = aDeviceCryptoSession() + fakeOlm.givenEncrypts(olmSessionToEncryptWith, expectedPayload(olmSessionToEncryptWith)).returns(AN_OLM_ENCRPYTION_RESULT) + + shareRoomKeyUseCase.invoke(A_ROOM_CRYPTO_SESSION, listOf(olmSessionToEncryptWith), A_ROOM_ID) + + coVerify { + fakeDeviceService.sendRoomKeyToDevice(A_ROOM_CRYPTO_SESSION.id, listOf(expectedToDeviceRoomShareMessage(olmSessionToEncryptWith))) + } + } + + private fun expectedToDeviceRoomShareMessage(olmSessionToEncryptWith: Olm.DeviceCryptoSession) = DeviceService.ToDeviceMessage( + olmSessionToEncryptWith.userId, + olmSessionToEncryptWith.deviceId, + ToDevicePayload.EncryptedToDevicePayload( + algorithmName = ALGORITHM_OLM, + senderKey = A_ROOM_CRYPTO_SESSION.accountCryptoSession.senderKey, + cipherText = mapOf( + olmSessionToEncryptWith.identity to ToDevicePayload.EncryptedToDevicePayload.Inner( + cipherText = AN_OLM_ENCRPYTION_RESULT.cipherText, + type = AN_OLM_ENCRPYTION_RESULT.type, + ) + ) + ) + ) + + private fun expectedPayload(deviceCryptoSession: Olm.DeviceCryptoSession) = mapOf( + "type" to "m.room_key", + "content" to mapOf( + "algorithm" to ALGORITHM_MEGOLM.value, + "room_id" to A_ROOM_ID.value, + "session_id" to A_ROOM_CRYPTO_SESSION.id.value, + "session_key" to A_ROOM_CRYPTO_SESSION.key, + "chain_index" to A_ROOM_CRYPTO_SESSION.messageIndex, + ), + "sender" to A_USER_CREDENTIALS.userId.value, + "sender_device" to A_USER_CREDENTIALS.deviceId.value, + "keys" to mapOf( + "ed25519" to A_ROOM_CRYPTO_SESSION.accountCryptoSession.fingerprint.value + ), + "recipient" to deviceCryptoSession.userId.value, + "recipient_keys" to mapOf( + "ed25519" to deviceCryptoSession.fingerprint.value + ) + ).toJsonString() +} \ No newline at end of file diff --git a/matrix/services/crypto/src/test/kotlin/internalfake/FakeFetchAccountCryptoUseCase.kt b/matrix/services/crypto/src/test/kotlin/internalfake/FakeFetchAccountCryptoUseCase.kt new file mode 100644 index 0000000..bc045da --- /dev/null +++ b/matrix/services/crypto/src/test/kotlin/internalfake/FakeFetchAccountCryptoUseCase.kt @@ -0,0 +1,12 @@ +package internalfake + +import app.dapk.st.matrix.crypto.Olm +import app.dapk.st.matrix.crypto.internal.FetchAccountCryptoUseCase +import io.mockk.coEvery +import io.mockk.mockk + +class FakeFetchAccountCryptoUseCase : FetchAccountCryptoUseCase by mockk() { + fun givenAccount(account: Olm.AccountCryptoSession) { + coEvery { this@FakeFetchAccountCryptoUseCase.invoke() } returns account + } +} \ No newline at end of file diff --git a/matrix/services/crypto/src/test/kotlin/internalfake/FakeFetchMegolmSessionUseCase.kt b/matrix/services/crypto/src/test/kotlin/internalfake/FakeFetchMegolmSessionUseCase.kt new file mode 100644 index 0000000..3a7bb6a --- /dev/null +++ b/matrix/services/crypto/src/test/kotlin/internalfake/FakeFetchMegolmSessionUseCase.kt @@ -0,0 +1,13 @@ +package internalfake + +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.crypto.Olm +import app.dapk.st.matrix.crypto.internal.FetchMegolmSessionUseCase +import io.mockk.coEvery +import io.mockk.mockk + +internal class FakeFetchMegolmSessionUseCase : FetchMegolmSessionUseCase by mockk() { + fun givenSessionForRoom(roomId: RoomId, roomCryptoSession: Olm.RoomCryptoSession) { + coEvery { this@FakeFetchMegolmSessionUseCase.invoke(roomId) } returns roomCryptoSession + } +} \ No newline at end of file diff --git a/matrix/services/crypto/src/test/kotlin/internalfake/FakeRegisterOlmSessionUseCase.kt b/matrix/services/crypto/src/test/kotlin/internalfake/FakeRegisterOlmSessionUseCase.kt new file mode 100644 index 0000000..ab297af --- /dev/null +++ b/matrix/services/crypto/src/test/kotlin/internalfake/FakeRegisterOlmSessionUseCase.kt @@ -0,0 +1,15 @@ +package internalfake + +import app.dapk.st.matrix.crypto.Olm +import app.dapk.st.matrix.crypto.internal.RegisterOlmSessionUseCase +import app.dapk.st.matrix.device.internal.DeviceKeys +import io.mockk.coEvery +import io.mockk.mockk +import test.Returns +import test.delegateReturn + +internal class FakeRegisterOlmSessionUseCase : RegisterOlmSessionUseCase by mockk() { + fun givenRegistersSessions(devices: List, account: Olm.AccountCryptoSession): Returns> { + return coEvery { this@FakeRegisterOlmSessionUseCase.invoke(devices, account) }.delegateReturn() + } +} \ No newline at end of file diff --git a/matrix/services/crypto/src/test/kotlin/internalfake/FakeShareRoomKeyUseCase.kt b/matrix/services/crypto/src/test/kotlin/internalfake/FakeShareRoomKeyUseCase.kt new file mode 100644 index 0000000..671bd45 --- /dev/null +++ b/matrix/services/crypto/src/test/kotlin/internalfake/FakeShareRoomKeyUseCase.kt @@ -0,0 +1,23 @@ +package internalfake + +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.crypto.Olm +import app.dapk.st.matrix.crypto.internal.ShareRoomKeyUseCase +import io.mockk.coJustRun +import io.mockk.mockk + +internal class FakeShareRoomKeyUseCase : ShareRoomKeyUseCase { + + private val instance = mockk() + + override suspend fun invoke(room: Olm.RoomCryptoSession, p2: List, p3: RoomId) { + instance.invoke(room, p2, p3) + } + + fun expect(roomCryptoSession: Olm.RoomCryptoSession, olmSessions: List, roomId: RoomId) { + coJustRun { + instance.invoke(roomCryptoSession, olmSessions, roomId) + } + } + +} \ No newline at end of file diff --git a/matrix/services/crypto/src/testFixtures/kotlin/fake/FakeOlm.kt b/matrix/services/crypto/src/testFixtures/kotlin/fake/FakeOlm.kt new file mode 100644 index 0000000..1e1f362 --- /dev/null +++ b/matrix/services/crypto/src/testFixtures/kotlin/fake/FakeOlm.kt @@ -0,0 +1,69 @@ +package fake + +import app.dapk.st.matrix.common.CipherText +import app.dapk.st.matrix.common.JsonString +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.UserCredentials +import app.dapk.st.matrix.crypto.Olm +import app.dapk.st.matrix.device.DeviceService +import app.dapk.st.matrix.device.internal.DeviceKeys +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.slot +import org.amshove.kluent.shouldBeEqualTo +import test.Returns +import test.delegateReturn + +class FakeOlm : Olm by mockk() { + + fun givenEncrypts(roomCryptoSession: Olm.RoomCryptoSession, roomId: RoomId, messageJson: JsonString, result: CipherText) { + coEvery { roomCryptoSession.encrypt(roomId, messageJson) } returns result + } + + fun givenEncrypts(olmSession: Olm.DeviceCryptoSession, messageJson: JsonString) = coEvery { olmSession.encrypt(messageJson) }.delegateReturn() + + fun givenCreatesAccount(credentials: UserCredentials): Returns { + val slot = slot Unit>() + val mockKStubScope = coEvery { ensureAccountCrypto(credentials, capture(slot)) } + return Returns { value -> + mockKStubScope coAnswers { + slot.captured.invoke(value) + value + } + } + } + + fun givenAccount(credentials: UserCredentials): Returns { + return coEvery { ensureAccountCrypto(credentials, any()) }.delegateReturn() + } + + fun givenRoomCrypto(roomId: RoomId, account: Olm.AccountCryptoSession) = coEvery { ensureRoomCrypto(roomId, account) }.delegateReturn() + + fun givenMissingOlmSessions(newDevices: List): Returns> { + val slot = slot) -> List>() + val mockKStubScope = coEvery { olmSessions(newDevices, capture(slot)) } + return Returns { value -> + mockKStubScope coAnswers { + slot.captured.invoke(newDevices).also { + value shouldBeEqualTo it + } + } + } + } + + fun givenGeneratesOneTimeKeys( + accountCryptoSession: Olm.AccountCryptoSession, + countToCreate: Int, + credentials: UserCredentials + ): Returns { + val slot = slot Unit>() + val mockKStubScope = coEvery { with(accountCryptoSession) { generateOneTimeKeys(countToCreate, credentials, capture(slot)) } } + return Returns { value -> + mockKStubScope coAnswers { + slot.captured.invoke(value) + } + } + } + + fun givenDeviceCrypto(input: Olm.OlmSessionInput, account: Olm.AccountCryptoSession) = coEvery { ensureDeviceCrypto(input, account) }.delegateReturn() +} \ No newline at end of file diff --git a/matrix/services/crypto/src/testFixtures/kotlin/fixture/CryptoSessionFixtures.kt b/matrix/services/crypto/src/testFixtures/kotlin/fixture/CryptoSessionFixtures.kt new file mode 100644 index 0000000..40ae5bc --- /dev/null +++ b/matrix/services/crypto/src/testFixtures/kotlin/fixture/CryptoSessionFixtures.kt @@ -0,0 +1,31 @@ +package fixture + +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.crypto.Olm +import app.dapk.st.matrix.device.internal.DeviceKeys +import io.mockk.mockk + +fun anAccountCryptoSession( + fingerprint: Ed25519 = aEd25519(), + senderKey: Curve25519 = aCurve25519(), + deviceKeys: DeviceKeys = aDeviceKeys(), + maxKeys: Int = 5, + olmAccount: Any = mockk(), +) = Olm.AccountCryptoSession(fingerprint, senderKey, deviceKeys, maxKeys, olmAccount) + +fun aRoomCryptoSession( + creationTimestampUtc: Long = 0L, + key: String = "a-room-key", + messageIndex: Int = 100, + accountCryptoSession: Olm.AccountCryptoSession = anAccountCryptoSession(), + id: SessionId = aSessionId("a-room-crypto-session-id"), + outBound: Any = mockk(), +) = Olm.RoomCryptoSession(creationTimestampUtc, key, messageIndex, accountCryptoSession, id, outBound) + +fun aDeviceCryptoSession( + deviceId: DeviceId = aDeviceId(), + userId: UserId = aUserId(), + identity: Curve25519 = aCurve25519(), + fingerprint: Ed25519 = aEd25519(), + olmSession: List = emptyList(), +) = Olm.DeviceCryptoSession(deviceId, userId, identity, fingerprint, olmSession) diff --git a/matrix/services/crypto/src/testFixtures/kotlin/fixture/FakeRoomMembersProvider.kt b/matrix/services/crypto/src/testFixtures/kotlin/fixture/FakeRoomMembersProvider.kt new file mode 100644 index 0000000..85303bb --- /dev/null +++ b/matrix/services/crypto/src/testFixtures/kotlin/fixture/FakeRoomMembersProvider.kt @@ -0,0 +1,11 @@ +package fixture + +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.crypto.RoomMembersProvider +import io.mockk.coEvery +import io.mockk.mockk +import test.delegateReturn + +class FakeRoomMembersProvider : RoomMembersProvider by mockk() { + fun givenUserIdsForRoom(roomId: RoomId) = coEvery { userIdsForRoom(roomId) }.delegateReturn() +} \ No newline at end of file diff --git a/matrix/services/device/build.gradle b/matrix/services/device/build.gradle new file mode 100644 index 0000000..ef46129 --- /dev/null +++ b/matrix/services/device/build.gradle @@ -0,0 +1,9 @@ +plugins { id 'java-test-fixtures' } +applyMatrixServiceModule(project) + +dependencies { + kotlinFixtures(it) + testFixturesImplementation(testFixtures(project(":matrix:common"))) + testFixturesImplementation(testFixtures(project(":core"))) + testFixturesImplementation Dependencies.mavenCentral.kotlinSerializationJson +} \ No newline at end of file diff --git a/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/DeviceService.kt b/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/DeviceService.kt new file mode 100644 index 0000000..c69d8d4 --- /dev/null +++ b/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/DeviceService.kt @@ -0,0 +1,140 @@ +package app.dapk.st.matrix.device + +import app.dapk.st.matrix.MatrixService +import app.dapk.st.matrix.MatrixServiceInstaller +import app.dapk.st.matrix.MatrixServiceProvider +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.device.internal.ClaimKeysResponse +import app.dapk.st.matrix.device.internal.DefaultDeviceService +import app.dapk.st.matrix.device.internal.DeviceKeys +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +private val SERVICE_KEY = DeviceService::class + +interface DeviceService : MatrixService { + + suspend fun uploadDeviceKeys(deviceKeys: DeviceKeys) + suspend fun uploadOneTimeKeys(oneTimeKeys: OneTimeKeys) + suspend fun fetchDevices(userIds: List, syncToken: SyncToken?): List + suspend fun checkForNewDevices(self: DeviceKeys, userIds: List, id: SessionId): List + suspend fun ensureDevice(userId: UserId, deviceId: DeviceId): DeviceKeys + suspend fun claimKeys(claims: List): ClaimKeysResponse + suspend fun sendRoomKeyToDevice(sessionId: SessionId, messages: List) + suspend fun sendToDevice(eventType: EventType, transactionId: String, userId: UserId, deviceId: DeviceId, payload: ToDevicePayload) + suspend fun updateStaleDevices(userIds: List) + + @JvmInline + value class OneTimeKeys(val keys: List) { + + sealed interface Key { + data class SignedCurve(val keyId: String, val value: String, val signature: Ed25519Signature) : Key { + data class Ed25519Signature(val value: SignedJson, val deviceId: DeviceId, val userId: UserId) + } + } + + } + + data class KeyClaim(val userId: UserId, val deviceId: DeviceId, val algorithmName: AlgorithmName) + + data class ToDeviceMessage( + val senderId: UserId, + val deviceId: DeviceId, + val encryptedMessage: ToDevicePayload.EncryptedToDevicePayload + ) +} + + +@Serializable +sealed class ToDevicePayload { + + @Serializable + data class EncryptedToDevicePayload( + @SerialName("algorithm") val algorithmName: AlgorithmName, + @SerialName("sender_key") val senderKey: Curve25519, + @SerialName("ciphertext") val cipherText: Map, + ) : ToDevicePayload() { + + @Serializable + data class Inner( + @SerialName("body") val cipherText: CipherText, + @SerialName("type") val type: Long, + ) + } + + @Serializable + data class VerificationRequest( + @SerialName("from_device") val fromDevice: DeviceId, + @SerialName("methods") val methods: List, + @SerialName("transaction_id") val transactionId: String, + @SerialName("timestamp") val timestampPosix: Long, + ) : ToDevicePayload(), VerificationPayload + + @Serializable + data class VerificationStart( + @SerialName("from_device") val fromDevice: DeviceId, + @SerialName("method") val method: String, + @SerialName("key_agreement_protocols") val protocols: List, + @SerialName("hashes") val hashes: List, + @SerialName("message_authentication_codes") val codes: List, + @SerialName("short_authentication_string") val short: List, + @SerialName("transaction_id") val transactionId: String, + ) : ToDevicePayload(), VerificationPayload + + @Serializable + data class VerificationAccept( + @SerialName("transaction_id") val transactionId: String, + @SerialName("from_device") val fromDevice: DeviceId, + @SerialName("method") val method: String, + @SerialName("key_agreement_protocol") val protocol: String, + @SerialName("hash") val hash: String, + @SerialName("message_authentication_code") val code: String, + @SerialName("short_authentication_string") val short: List, + @SerialName("commitment") val commitment: String, + ) : ToDevicePayload(), VerificationPayload + + @Serializable + data class VerificationReady( + @SerialName("from_device") val fromDevice: DeviceId, + @SerialName("methods") val methods: List, + @SerialName("transaction_id") val transactionId: String, + ) : ToDevicePayload(), VerificationPayload + + @Serializable + data class VerificationKey( + @SerialName("transaction_id") val transactionId: String, + @SerialName("key") val key: String, + ) : ToDevicePayload(), VerificationPayload + + @Serializable + data class VerificationMac( + @SerialName("transaction_id") val transactionId: String, + @SerialName("keys") val keys: String, + @SerialName("mac") val mac: Map, + ) : ToDevicePayload(), VerificationPayload + + @Serializable + data class VerificationDone( + @SerialName("transaction_id") val transactionId: String, + ) : ToDevicePayload(), VerificationPayload + + + sealed interface VerificationPayload +} + +fun MatrixServiceInstaller.installEncryptionService(knownDeviceStore: KnownDeviceStore) { + this.install { (httpClient, _, _, logger) -> + SERVICE_KEY to DefaultDeviceService(httpClient, logger, knownDeviceStore) + } +} + +fun MatrixServiceProvider.deviceService(): DeviceService = this.getService(key = SERVICE_KEY) + +interface KnownDeviceStore { + suspend fun updateDevices(devices: Map>): List + suspend fun markOutdated(userIds: List) + suspend fun maybeConsumeOutdated(userIds: List): List + suspend fun devicesMegolmSession(userIds: List, sessionId: SessionId): List + suspend fun associateSession(sessionId: SessionId, deviceIds: List) + suspend fun device(userId: UserId, deviceId: DeviceId): DeviceKeys? +} \ No newline at end of file diff --git a/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/DefaultDeviceService.kt b/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/DefaultDeviceService.kt new file mode 100644 index 0000000..1bfbe86 --- /dev/null +++ b/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/DefaultDeviceService.kt @@ -0,0 +1,162 @@ +package app.dapk.st.matrix.device.internal + +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.device.DeviceService +import app.dapk.st.matrix.device.DeviceService.OneTimeKeys.Key.SignedCurve.Ed25519Signature +import app.dapk.st.matrix.device.KnownDeviceStore +import app.dapk.st.matrix.device.ToDevicePayload +import app.dapk.st.matrix.http.MatrixHttpClient +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import java.util.* + +internal class DefaultDeviceService( + private val httpClient: MatrixHttpClient, + private val logger: MatrixLogger, + private val knownDeviceStore: KnownDeviceStore, +) : DeviceService { + + override suspend fun uploadOneTimeKeys(oneTimeKeys: DeviceService.OneTimeKeys) { + val jsonCryptoKeys = oneTimeKeys.keys.associate { + when (it) { + is DeviceService.OneTimeKeys.Key.SignedCurve -> { + "signed_curve25519:${it.keyId}" to JsonObject( + content = mapOf( + "key" to JsonPrimitive(it.value), + "signatures" to it.signature.toJson() + ) + ) + } + } + } + + val keyRequest = UploadKeyRequest( + deviceKeys = null, + oneTimeKeys = jsonCryptoKeys + ) + logger.matrixLog("uploading one time keys") + logger.matrixLog(jsonCryptoKeys) + httpClient.execute(uploadKeysRequest(keyRequest)).also { + logger.matrixLog(it) + } + } + + override suspend fun uploadDeviceKeys(deviceKeys: DeviceKeys) { + logger.matrixLog("uploading device keys") + val keyRequest = UploadKeyRequest( + deviceKeys = deviceKeys, + oneTimeKeys = null + ) + logger.matrixLog(keyRequest) + httpClient.execute(uploadKeysRequest(keyRequest)).also { + logger.matrixLog(it) + } + } + + private fun Ed25519Signature.toJson() = JsonObject( + content = mapOf( + this.userId.value to JsonObject( + content = mapOf( + "ed25519:${this.deviceId.value}" to JsonPrimitive(this.value.value) + ) + ) + ) + ) + + override suspend fun fetchDevices(userIds: List, syncToken: SyncToken?): List { + val request = QueryKeysRequest( + deviceKeys = userIds.associateWith { emptyList() }, + token = syncToken?.value, + ) + + logger.crypto("querying keys for: $userIds") + val apiResponse = httpClient.execute(queryKeys(request)) + logger.crypto("got keys for ${apiResponse.deviceKeys.keys}") + + return apiResponse.deviceKeys.values.map { it.values }.flatten().also { + knownDeviceStore.updateDevices(apiResponse.deviceKeys) + } + } + + override suspend fun claimKeys(claims: List): ClaimKeysResponse { + val request = ClaimKeysRequest(oneTimeKeys = claims.groupBy { it.userId }.mapValues { + it.value.associate { it.deviceId to it.algorithmName } + }) + return httpClient.execute(claimKeys(request)) + } + + override suspend fun sendRoomKeyToDevice(sessionId: SessionId, messages: List) { + val associateBy = messages.groupBy { it.senderId }.mapValues { + it.value.associateBy { it.deviceId }.mapValues { it.value.encryptedMessage } + } + + logger.crypto("sending to device: ${associateBy.map { it.key to it.value.keys }}") + + val txnId = UUID.randomUUID().toString() + httpClient.execute(sendToDeviceRequest(EventType.ENCRYPTED, txnId, SendToDeviceRequest(associateBy))) + knownDeviceStore.associateSession(sessionId, messages.map { it.deviceId }) + } + + override suspend fun sendToDevice(eventType: EventType, transactionId: String, userId: UserId, deviceId: DeviceId, payload: ToDevicePayload) { + val messages = mapOf( + userId to mapOf( + deviceId to payload + ) + ) + httpClient.execute(sendToDeviceRequest(eventType, transactionId, SendToDeviceRequest(messages))) + } + + override suspend fun updateStaleDevices(userIds: List) { + logger.matrixLog("devices changed: $userIds") + knownDeviceStore.markOutdated(userIds) + } + + override suspend fun checkForNewDevices(self: DeviceKeys, userIds: List, id: SessionId): List { + val outdatedUsersToNotify = knownDeviceStore.maybeConsumeOutdated(userIds) + logger.crypto("found outdated users: $outdatedUsersToNotify") + val notOutdatedIds = userIds.filterNot { outdatedUsersToNotify.contains(it) } + val knownKeys = knownDeviceStore.devicesMegolmSession(notOutdatedIds, id) + + val knownUsers = knownKeys.map { it.userId } + val usersWithoutKnownSessions = notOutdatedIds - knownUsers.toSet() + logger.crypto("found users without known sessions: $usersWithoutKnownSessions") + + val usersToUpdate = outdatedUsersToNotify + usersWithoutKnownSessions + val newDevices = if (usersToUpdate.isNotEmpty()) { + fetchDevices(usersToUpdate, syncToken = null).filter { + it.deviceId != self.deviceId + } + } else { + logger.crypto("didn't find any new devices") + emptyList() + } + + return newDevices + } + + override suspend fun ensureDevice(userId: UserId, deviceId: DeviceId): DeviceKeys { + return knownDeviceStore.device(userId, deviceId) ?: fetchDevices(listOf(userId), syncToken = null).first() + } +} + +@Serializable +sealed class ApiMessage { + + @Serializable + @SerialName("text_message") + data class TextMessage( + @SerialName("content") val content: TextContent, + @SerialName("room_id") val roomId: RoomId, + @SerialName("type") val type: String, + ) : ApiMessage() { + + @Serializable + data class TextContent( + @SerialName("body") val body: String, + @SerialName("msgtype") val type: String = MessageType.TEXT.value, + ) + } + +} diff --git a/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/EncyptionRequests.kt b/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/EncyptionRequests.kt new file mode 100644 index 0000000..014dbc5 --- /dev/null +++ b/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/EncyptionRequests.kt @@ -0,0 +1,88 @@ +package app.dapk.st.matrix.device.internal + +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.device.ToDevicePayload +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.http.MatrixHttpClient.HttpRequest.Companion.httpRequest +import app.dapk.st.matrix.http.jsonBody +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +internal fun uploadKeysRequest(keyRequest: UploadKeyRequest) = httpRequest( + path = "_matrix/client/r0/keys/upload", + method = MatrixHttpClient.Method.POST, + body = jsonBody(keyRequest, MatrixHttpClient.jsonWithDefaults), +) + +internal fun queryKeys(queryRequest: QueryKeysRequest) = httpRequest( + path = "_matrix/client/r0/keys/query", + method = MatrixHttpClient.Method.POST, + body = jsonBody(queryRequest, MatrixHttpClient.jsonWithDefaults), +) + + +internal fun claimKeys(claimRequest: ClaimKeysRequest) = httpRequest( + path = "_matrix/client/r0/keys/claim", + method = MatrixHttpClient.Method.POST, + body = jsonBody(claimRequest, MatrixHttpClient.jsonWithDefaults), +) + +internal fun sendToDeviceRequest(eventType: EventType, txnId: String, request: SendToDeviceRequest) = httpRequest( + path = "_matrix/client/r0/sendToDevice/${eventType.value}/${txnId}", + method = MatrixHttpClient.Method.PUT, + body = jsonBody(request) +) + +@Serializable +internal data class UploadKeysResponse( + @SerialName("one_time_key_counts") val keyCounts: Map +) + +@Serializable +internal data class SendToDeviceRequest( + @SerialName("messages") val messages: Map> +) + + +@Serializable +internal data class UploadKeyRequest( + @SerialName("device_keys") val deviceKeys: DeviceKeys? = null, + @SerialName("one_time_keys") val oneTimeKeys: Map? = null, +) + +@Serializable +internal data class QueryKeysRequest( + @SerialName("timeout") val timeout: Int = 10000, + @SerialName("device_keys") val deviceKeys: Map>, + @SerialName("token") val token: String? = null, +) + +@Serializable +internal data class QueryKeysResponse( + @SerialName("device_keys") val deviceKeys: Map> +) + +@Serializable +internal data class ClaimKeysRequest( + @SerialName("timeout") val timeout: Int = 10000, + @SerialName("one_time_keys") val oneTimeKeys: Map>, +) + +@Serializable +data class ClaimKeysResponse( + @SerialName("one_time_keys") val oneTimeKeys: Map>, + @SerialName("failures") val failures: Map +) + +@Serializable +data class DeviceKeys( + @SerialName("user_id") val userId: UserId, + @SerialName("device_id") val deviceId: DeviceId, + @SerialName("algorithms") val algorithms: List, + @SerialName("keys") val keys: Map, + @SerialName("signatures") val signatures: Map>, +) { + fun fingerprint() = Ed25519(keys["ed25519:${deviceId.value}"]!!) + fun identity() = Curve25519(keys["curve25519:${deviceId.value}"]!!) +} \ No newline at end of file diff --git a/matrix/services/device/src/testFixtures/kotlin/fake/FakeDeviceService.kt b/matrix/services/device/src/testFixtures/kotlin/fake/FakeDeviceService.kt new file mode 100644 index 0000000..78ed843 --- /dev/null +++ b/matrix/services/device/src/testFixtures/kotlin/fake/FakeDeviceService.kt @@ -0,0 +1,23 @@ +package fake + +import app.dapk.st.matrix.common.SessionId +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.device.DeviceService +import app.dapk.st.matrix.device.internal.DeviceKeys +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import test.Returns +import test.delegateReturn + +class FakeDeviceService : DeviceService by mockk() { + fun givenNewDevices(accountKeys: DeviceKeys, usersInRoom: List, roomCryptoSessionId: SessionId): Returns> { + return coEvery { checkForNewDevices(accountKeys, usersInRoom, roomCryptoSessionId) }.delegateReturn() + } + + fun verifyDidntUploadOneTimeKeys() { + coVerify(exactly = 0) { this@FakeDeviceService.uploadOneTimeKeys(DeviceService.OneTimeKeys(any())) } + } + + fun givenClaimsKeys(claims: List) = coEvery { claimKeys(claims) }.delegateReturn() +} \ No newline at end of file diff --git a/matrix/services/device/src/testFixtures/kotlin/fixture/ClaimKeysResponseFixture.kt b/matrix/services/device/src/testFixtures/kotlin/fixture/ClaimKeysResponseFixture.kt new file mode 100644 index 0000000..90415c0 --- /dev/null +++ b/matrix/services/device/src/testFixtures/kotlin/fixture/ClaimKeysResponseFixture.kt @@ -0,0 +1,11 @@ +package fixture + +import app.dapk.st.matrix.common.DeviceId +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.device.internal.ClaimKeysResponse +import kotlinx.serialization.json.JsonElement + +fun aClaimKeysResponse( + oneTimeKeys: Map> = emptyMap(), + failures: Map = emptyMap() +) = ClaimKeysResponse(oneTimeKeys, failures) \ No newline at end of file diff --git a/matrix/services/device/src/testFixtures/kotlin/fixture/DeviceKeysFixutre.kt b/matrix/services/device/src/testFixtures/kotlin/fixture/DeviceKeysFixutre.kt new file mode 100644 index 0000000..40d5e3c --- /dev/null +++ b/matrix/services/device/src/testFixtures/kotlin/fixture/DeviceKeysFixutre.kt @@ -0,0 +1,15 @@ +package fixture + +import app.dapk.st.matrix.common.AlgorithmName +import app.dapk.st.matrix.common.DeviceId +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.device.internal.DeviceKeys + + +fun aDeviceKeys( + userId: UserId = aUserId(), + deviceId: DeviceId = aDeviceId(), + algorithms: List = listOf(anAlgorithmName()), + keys: Map = emptyMap(), + signatures: Map> = emptyMap(), +) = DeviceKeys(userId, deviceId, algorithms, keys, signatures) \ No newline at end of file diff --git a/matrix/services/device/src/testFixtures/kotlin/fixture/KeyClaimFixture.kt b/matrix/services/device/src/testFixtures/kotlin/fixture/KeyClaimFixture.kt new file mode 100644 index 0000000..790197c --- /dev/null +++ b/matrix/services/device/src/testFixtures/kotlin/fixture/KeyClaimFixture.kt @@ -0,0 +1,12 @@ +package fixture + +import app.dapk.st.matrix.common.AlgorithmName +import app.dapk.st.matrix.common.DeviceId +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.device.DeviceService + +fun aKeyClaim( + userId: UserId = aUserId(), + deviceId: DeviceId = aDeviceId(), + algorithmName: AlgorithmName = anAlgorithmName(), +) = DeviceService.KeyClaim(userId, deviceId, algorithmName) \ No newline at end of file diff --git a/matrix/services/message/build.gradle b/matrix/services/message/build.gradle new file mode 100644 index 0000000..3dcc229 --- /dev/null +++ b/matrix/services/message/build.gradle @@ -0,0 +1 @@ +applyMatrixServiceModule(project) diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/ApiSendResponse.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/ApiSendResponse.kt new file mode 100644 index 0000000..ebcefaf --- /dev/null +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/ApiSendResponse.kt @@ -0,0 +1,10 @@ +package app.dapk.st.matrix.message + +import app.dapk.st.matrix.common.EventId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ApiSendResponse( + @SerialName("event_id") val eventId: EventId, +) \ No newline at end of file diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/BackgroundScheduler.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/BackgroundScheduler.kt new file mode 100644 index 0000000..e740ade --- /dev/null +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/BackgroundScheduler.kt @@ -0,0 +1,9 @@ +package app.dapk.st.matrix.message + +interface BackgroundScheduler { + + fun schedule(key: String, task: Task) + + data class Task(val type: String, val jsonPayload: String) +} + diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageEncrypter.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageEncrypter.kt new file mode 100644 index 0000000..94300cd --- /dev/null +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageEncrypter.kt @@ -0,0 +1,26 @@ +package app.dapk.st.matrix.message + +import app.dapk.st.matrix.common.AlgorithmName +import app.dapk.st.matrix.common.CipherText +import app.dapk.st.matrix.common.DeviceId +import app.dapk.st.matrix.common.SessionId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +fun interface MessageEncrypter { + + suspend fun encrypt(message: MessageService.Message): EncryptedMessagePayload + + @Serializable + data class EncryptedMessagePayload( + @SerialName("algorithm") val algorithmName: AlgorithmName, + @SerialName("sender_key") val senderKey: String, + @SerialName("ciphertext") val cipherText: CipherText, + @SerialName("session_id") val sessionId: SessionId, + @SerialName("device_id") val deviceId: DeviceId + ) +} + +internal object MissingMessageEncrypter : MessageEncrypter { + override suspend fun encrypt(message: MessageService.Message) = throw IllegalStateException("No encrypter instance set") +} \ No newline at end of file diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt new file mode 100644 index 0000000..e49360a --- /dev/null +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt @@ -0,0 +1,118 @@ +package app.dapk.st.matrix.message + +import app.dapk.st.matrix.MatrixService +import app.dapk.st.matrix.MatrixServiceInstaller +import app.dapk.st.matrix.MatrixServiceProvider +import app.dapk.st.matrix.ServiceDepFactory +import app.dapk.st.matrix.common.AlgorithmName +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.MessageType +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.message.internal.DefaultMessageService +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.util.* + +private val SERVICE_KEY = MessageService::class + +interface MessageService : MatrixService { + + fun localEchos(roomId: RoomId): Flow> + fun localEchos(): Flow>> + + suspend fun sendMessage(message: Message) + suspend fun scheduleMessage(message: Message) + suspend fun sendEventMessage(roomId: RoomId, message: EventMessage) + + sealed interface EventMessage { + + @Serializable + data class Encryption( + @SerialName("algorithm") val algorithm: AlgorithmName + ) : EventMessage + + } + + @Serializable + sealed class Message { + @Serializable + @SerialName("text_message") + data class TextMessage( + @SerialName("content") val content: Content.TextContent, + @SerialName("send_encrypted") val sendEncrypted: Boolean, + @SerialName("room_id") val roomId: RoomId, + @SerialName("local_id") val localId: String = "local.${UUID.randomUUID()}", + @SerialName("timestamp") val timestampUtc: Long = System.currentTimeMillis(), + ) : Message() + + @Serializable + sealed class Content { + @Serializable + data class TextContent( + @SerialName("body") val body: String, + @SerialName("msgtype") val type: String = MessageType.TEXT.value, + ) : Content() + } + } + + @Serializable + data class LocalEcho( + @SerialName("event_id") val eventId: EventId?, + @SerialName("message") val message: Message, + @SerialName("state") val state: State, + ) { + + @Transient + val timestampUtc = when (message) { + is Message.TextMessage -> message.timestampUtc + } + + @Transient + val roomId = when (message) { + is Message.TextMessage -> message.roomId + } + + @Transient + val localId = when (message) { + is Message.TextMessage -> message.localId + } + + @Serializable + sealed class State { + @Serializable + @SerialName("sending") + object Sending : State() + + @Serializable + @SerialName("sent") + object Sent : State() + + @Serializable + @SerialName("error") + data class Error( + @SerialName("message") val message: String, + @SerialName("error_type") val errorType: Type, + ) : State() { + + @Serializable + enum class Type { + UNKNOWN + } + } + } + } + +} + +fun MatrixServiceInstaller.installMessageService( + localEchoStore: LocalEchoStore, + backgroundScheduler: BackgroundScheduler, + messageEncrypter: ServiceDepFactory = ServiceDepFactory { MissingMessageEncrypter }, +) { + this.install { (httpClient, _, installedServices) -> + SERVICE_KEY to DefaultMessageService(httpClient, localEchoStore, backgroundScheduler, messageEncrypter.create(installedServices)) + } +} + +fun MatrixServiceProvider.messageService(): MessageService = this.getService(key = SERVICE_KEY) diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/Store.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/Store.kt new file mode 100644 index 0000000..92c7a30 --- /dev/null +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/Store.kt @@ -0,0 +1,14 @@ +package app.dapk.st.matrix.message + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import kotlinx.coroutines.flow.Flow + +interface LocalEchoStore { + + suspend fun preload() + suspend fun messageTransaction(message: MessageService.Message, action: suspend () -> EventId) + fun observeLocalEchos(roomId: RoomId): Flow> + fun observeLocalEchos(): Flow>> + fun markSending(message: MessageService.Message) +} diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt new file mode 100644 index 0000000..c4f6f72 --- /dev/null +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt @@ -0,0 +1,72 @@ +package app.dapk.st.matrix.message.internal + +import app.dapk.st.matrix.MatrixTaskRunner +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.message.BackgroundScheduler +import app.dapk.st.matrix.message.LocalEchoStore +import app.dapk.st.matrix.message.MessageEncrypter +import app.dapk.st.matrix.message.MessageService +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.json.Json +import java.net.SocketException +import java.net.UnknownHostException + +internal class DefaultMessageService( + httpClient: MatrixHttpClient, + private val localEchoStore: LocalEchoStore, + private val backgroundScheduler: BackgroundScheduler, + messageEncrypter: MessageEncrypter, +) : MessageService, MatrixTaskRunner { + + private val sendMessageUseCase = SendMessageUseCase(httpClient, messageEncrypter) + private val sendEventMessageUseCase = SendEventMessageUseCase(httpClient) + + override suspend fun canRun(task: MatrixTaskRunner.MatrixTask) = task.type == "text-message" + + override suspend fun run(task: MatrixTaskRunner.MatrixTask): MatrixTaskRunner.TaskResult { + require(task.type == "text-message") + val message = Json.decodeFromString(MessageService.Message.TextMessage.serializer(), task.jsonPayload) + return try { + sendMessage(message) + MatrixTaskRunner.TaskResult.Success + } catch (error: Throwable) { + val canRetry = error is UnknownHostException || error is SocketException + MatrixTaskRunner.TaskResult.Failure(canRetry) + } + } + + override fun localEchos(roomId: RoomId): Flow> { + return localEchoStore.observeLocalEchos(roomId) + } + + override fun localEchos(): Flow>> { + return localEchoStore.observeLocalEchos() + } + + override suspend fun scheduleMessage(message: MessageService.Message) { + localEchoStore.markSending(message) + val localId = when (message) { + is MessageService.Message.TextMessage -> message.localId + } + backgroundScheduler.schedule(key = localId, message.toTask()) + } + + override suspend fun sendMessage(message: MessageService.Message) { + localEchoStore.messageTransaction(message) { + sendMessageUseCase.sendMessage(message) + } + } + + private fun MessageService.Message.toTask(): BackgroundScheduler.Task { + return when (this) { + is MessageService.Message.TextMessage -> { + BackgroundScheduler.Task(type = "text-message", Json.encodeToString(MessageService.Message.TextMessage.serializer(), this)) + } + } + } + + override suspend fun sendEventMessage(roomId: RoomId, message: MessageService.EventMessage) { + sendEventMessageUseCase.sendMessage(roomId, message) + } +} \ No newline at end of file diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendEventMessageUseCase.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendEventMessageUseCase.kt new file mode 100644 index 0000000..30f135c --- /dev/null +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendEventMessageUseCase.kt @@ -0,0 +1,27 @@ +package app.dapk.st.matrix.message.internal + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.EventType +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.message.MessageService + +internal class SendEventMessageUseCase( + private val httpClient: MatrixHttpClient, +) { + + suspend fun sendMessage(roomId: RoomId, message: MessageService.EventMessage): EventId { + return when (message) { + is MessageService.EventMessage.Encryption -> { + httpClient.execute( + sendRequest( + roomId = roomId, + eventType = EventType.ENCRYPTION, + content = message, + ) + ).eventId + } + } + } + +} diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt new file mode 100644 index 0000000..6a6e2be --- /dev/null +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt @@ -0,0 +1,40 @@ +package app.dapk.st.matrix.message.internal + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.EventType +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.message.MessageEncrypter +import app.dapk.st.matrix.message.MessageService + +internal class SendMessageUseCase( + private val httpClient: MatrixHttpClient, + private val messageEncrypter: MessageEncrypter, +) { + + suspend fun sendMessage(message: MessageService.Message): EventId { + return when (message) { + is MessageService.Message.TextMessage -> { + val request = when (message.sendEncrypted) { + true -> { + sendRequest( + roomId = message.roomId, + eventType = EventType.ENCRYPTED, + txId = message.localId, + content = messageEncrypter.encrypt(message), + ) + } + false -> { + sendRequest( + roomId = message.roomId, + eventType = EventType.ROOM_MESSAGE, + txId = message.localId, + content = message.content, + ) + } + } + httpClient.execute(request).eventId + } + } + } + +} diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt new file mode 100644 index 0000000..53df1c3 --- /dev/null +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt @@ -0,0 +1,36 @@ +package app.dapk.st.matrix.message.internal + +import app.dapk.st.matrix.common.EventType +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.http.MatrixHttpClient.HttpRequest.Companion.httpRequest +import app.dapk.st.matrix.http.jsonBody +import app.dapk.st.matrix.message.ApiSendResponse +import app.dapk.st.matrix.message.MessageEncrypter +import app.dapk.st.matrix.message.MessageService.EventMessage +import app.dapk.st.matrix.message.MessageService.Message +import java.util.* + +internal fun sendRequest(roomId: RoomId, eventType: EventType, txId: String, content: Message.Content) = httpRequest( + path = "_matrix/client/r0/rooms/${roomId.value}/send/${eventType.value}/${txId}", + method = MatrixHttpClient.Method.PUT, + body = when (content) { + is Message.Content.TextContent -> jsonBody(Message.Content.TextContent.serializer(), content, MatrixHttpClient.jsonWithDefaults) + } +) + +internal fun sendRequest(roomId: RoomId, eventType: EventType, txId: String, content: MessageEncrypter.EncryptedMessagePayload) = httpRequest( + path = "_matrix/client/r0/rooms/${roomId.value}/send/${eventType.value}/${txId}", + method = MatrixHttpClient.Method.PUT, + body = jsonBody(MessageEncrypter.EncryptedMessagePayload.serializer(), content) +) + +internal fun sendRequest(roomId: RoomId, eventType: EventType, content: EventMessage) = httpRequest( + path = "_matrix/client/r0/rooms/${roomId.value}/send/${eventType.value}/${txId()}", + method = MatrixHttpClient.Method.PUT, + body = when (content) { + is EventMessage.Encryption -> jsonBody(EventMessage.Encryption.serializer(), content, MatrixHttpClient.jsonWithDefaults) + } +) + +fun txId() = "local.${UUID.randomUUID()}" \ No newline at end of file diff --git a/matrix/services/profile/build.gradle b/matrix/services/profile/build.gradle new file mode 100644 index 0000000..eaa3259 --- /dev/null +++ b/matrix/services/profile/build.gradle @@ -0,0 +1,5 @@ +applyMatrixServiceModule(project) + +dependencies { + implementation project(":core") +} \ No newline at end of file diff --git a/matrix/services/profile/src/main/kotlin/app/dapk/st/matrix/room/ProfileService.kt b/matrix/services/profile/src/main/kotlin/app/dapk/st/matrix/room/ProfileService.kt new file mode 100644 index 0000000..28ba329 --- /dev/null +++ b/matrix/services/profile/src/main/kotlin/app/dapk/st/matrix/room/ProfileService.kt @@ -0,0 +1,38 @@ +package app.dapk.st.matrix.room + +import app.dapk.st.core.SingletonFlows +import app.dapk.st.matrix.MatrixService +import app.dapk.st.matrix.MatrixServiceInstaller +import app.dapk.st.matrix.MatrixServiceProvider +import app.dapk.st.matrix.common.AvatarUrl +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.common.HomeServerUrl +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.room.internal.DefaultProfileService + +private val SERVICE_KEY = ProfileService::class + +interface ProfileService : MatrixService { + + suspend fun me(forceRefresh: Boolean): Me + + data class Me( + val userId: UserId, + val displayName: String?, + val avatarUrl: AvatarUrl?, + val homeServerUrl: HomeServerUrl, + ) + +} + +fun MatrixServiceInstaller.installProfileService( + profileStore: ProfileStore, + singletonFlows: SingletonFlows, + credentialsStore: CredentialsStore, +) { + this.install { (httpClient, _, _, logger) -> + SERVICE_KEY to DefaultProfileService(httpClient, logger, profileStore, singletonFlows, credentialsStore) + } +} + +fun MatrixServiceProvider.profileService(): ProfileService = this.getService(key = SERVICE_KEY) diff --git a/matrix/services/profile/src/main/kotlin/app/dapk/st/matrix/room/ProfileStore.kt b/matrix/services/profile/src/main/kotlin/app/dapk/st/matrix/room/ProfileStore.kt new file mode 100644 index 0000000..991bd18 --- /dev/null +++ b/matrix/services/profile/src/main/kotlin/app/dapk/st/matrix/room/ProfileStore.kt @@ -0,0 +1,8 @@ +package app.dapk.st.matrix.room + +interface ProfileStore { + + suspend fun storeMe(me: ProfileService.Me) + suspend fun readMe(): ProfileService.Me? + +} \ No newline at end of file diff --git a/matrix/services/profile/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultProfileService.kt b/matrix/services/profile/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultProfileService.kt new file mode 100644 index 0000000..5a39c53 --- /dev/null +++ b/matrix/services/profile/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultProfileService.kt @@ -0,0 +1,51 @@ +package app.dapk.st.matrix.room.internal + +import app.dapk.st.core.SingletonFlows +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.room.ProfileService +import app.dapk.st.matrix.room.ProfileStore +import kotlinx.coroutines.flow.first +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +internal class DefaultProfileService( + private val httpClient: MatrixHttpClient, + private val logger: MatrixLogger, + private val profileStore: ProfileStore, + private val singletonFlows: SingletonFlows, + private val credentialsStore: CredentialsStore, +) : ProfileService { + + override suspend fun me(forceRefresh: Boolean): ProfileService.Me { + return when (forceRefresh) { + true -> fetchMe().also { profileStore.storeMe(it) } + false -> singletonFlows.getOrPut("me") { + profileStore.readMe() ?: fetchMe().also { profileStore.storeMe(it) } + }.first() + } + } + + private suspend fun fetchMe(): ProfileService.Me { + val credentials = credentialsStore.credentials()!! + val userId = credentials.userId + val result = httpClient.execute(profileRequest(userId)) + return ProfileService.Me( + userId, + result.displayName, + result.avatarUrl?.convertMxUrToUrl(credentials.homeServer)?.let { AvatarUrl(it) }, + homeServerUrl = credentials.homeServer, + ) + } +} + +internal fun profileRequest(userId: UserId) = MatrixHttpClient.HttpRequest.httpRequest( + path = "_matrix/client/r0/profile/${userId.value}/", + method = MatrixHttpClient.Method.GET, +) + +@Serializable +internal data class ApiMe( + @SerialName("displayname") val displayName: String? = null, + @SerialName("avatar_url") val avatarUrl: MxUrl? = null, +) \ No newline at end of file diff --git a/matrix/services/push/build.gradle b/matrix/services/push/build.gradle new file mode 100644 index 0000000..3dcc229 --- /dev/null +++ b/matrix/services/push/build.gradle @@ -0,0 +1 @@ +applyMatrixServiceModule(project) diff --git a/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/PushService.kt b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/PushService.kt new file mode 100644 index 0000000..b985c07 --- /dev/null +++ b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/PushService.kt @@ -0,0 +1,48 @@ +package app.dapk.st.matrix.push + +import app.dapk.st.matrix.MatrixClient +import app.dapk.st.matrix.MatrixService +import app.dapk.st.matrix.MatrixServiceInstaller +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.push.internal.DefaultPushService +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +private val SERVICE_KEY = PushService::class + +interface PushService : MatrixService { + + suspend fun registerPush(token: String) + + @Serializable + data class PushRequest( + @SerialName("pushkey") val pushKey: String, + @SerialName("kind") val kind: String?, + @SerialName("app_id") val appId: String, + @SerialName("app_display_name") val appDisplayName: String? = null, + @SerialName("device_display_name") val deviceDisplayName: String? = null, + @SerialName("profile_tag") val profileTag: String? = null, + @SerialName("lang") val lang: String? = null, + @SerialName("data") val data: Payload? = null, + @SerialName("append") val append: Boolean? = false, + ) { + + @Serializable + data class Payload( + @SerialName("url") val url: String, + @SerialName("format") val format: String? = null, + @SerialName("brand") val brand: String? = null, + ) + } +} + +fun MatrixServiceInstaller.installPushService( + credentialsStore: CredentialsStore, +) { + this.install { (httpClient, _, _, logger) -> + SERVICE_KEY to DefaultPushService(httpClient, credentialsStore, logger) + } +} + +fun MatrixClient.pushService(): PushService = this.getService(key = SERVICE_KEY) + diff --git a/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/DefaultPushService.kt b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/DefaultPushService.kt new file mode 100644 index 0000000..7b9945e --- /dev/null +++ b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/DefaultPushService.kt @@ -0,0 +1,20 @@ +package app.dapk.st.matrix.push.internal + +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.common.MatrixLogger +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.push.PushService + +class DefaultPushService( + httpClient: MatrixHttpClient, + credentialsStore: CredentialsStore, + logger: MatrixLogger, +) : PushService { + + private val useCase = RegisterPushUseCase(httpClient, credentialsStore, logger) + + override suspend fun registerPush(token: String) { + useCase.registerPushToken(token) + } + +} \ No newline at end of file diff --git a/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/PushRequest.kt b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/PushRequest.kt new file mode 100644 index 0000000..2989870 --- /dev/null +++ b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/PushRequest.kt @@ -0,0 +1,12 @@ +package app.dapk.st.matrix.push.internal + +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.http.MatrixHttpClient.HttpRequest.Companion.httpRequest +import app.dapk.st.matrix.http.jsonBody +import app.dapk.st.matrix.push.PushService + +fun registerPushRequest(pushRequest: PushService.PushRequest) = httpRequest( + path = "_matrix/client/r0/pushers/set", + method = MatrixHttpClient.Method.POST, + body = jsonBody(PushService.PushRequest.serializer(), pushRequest, MatrixHttpClient.jsonWithDefaults), +) \ No newline at end of file diff --git a/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/RegisterPushUseCase.kt b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/RegisterPushUseCase.kt new file mode 100644 index 0000000..c1e8587 --- /dev/null +++ b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/RegisterPushUseCase.kt @@ -0,0 +1,39 @@ +package app.dapk.st.matrix.push.internal + +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.common.MatrixLogger +import app.dapk.st.matrix.common.isSignedIn +import app.dapk.st.matrix.common.matrixLog +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.push.PushService.PushRequest + +internal class RegisterPushUseCase( + private val matrixClient: MatrixHttpClient, + private val credentialsStore: CredentialsStore, + private val logger: MatrixLogger, +) { + + suspend fun registerPushToken(token: String) { + if (credentialsStore.isSignedIn()) { + logger.matrixLog("register push token: $token") + matrixClient.execute( + registerPushRequest( + PushRequest( + pushKey = token, + kind = "http", + appId = "app.dapk.st", + appDisplayName = "st-android", + deviceDisplayName = "device-a", + lang = "en", + profileTag = "mobile_${credentialsStore.credentials()!!.userId.hashCode()}", + append = false, + data = PushRequest.Payload( + format = "event_id_only", + url = "https://sygnal.dapk.app/_matrix/push/v1/notify", + ), + ) + ) + ) + } + } +} \ No newline at end of file diff --git a/matrix/services/room/build.gradle b/matrix/services/room/build.gradle new file mode 100644 index 0000000..3dcc229 --- /dev/null +++ b/matrix/services/room/build.gradle @@ -0,0 +1 @@ +applyMatrixServiceModule(project) diff --git a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt new file mode 100644 index 0000000..0f4164b --- /dev/null +++ b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt @@ -0,0 +1,55 @@ +package app.dapk.st.matrix.room + +import app.dapk.st.matrix.MatrixService +import app.dapk.st.matrix.MatrixServiceInstaller +import app.dapk.st.matrix.MatrixServiceProvider +import app.dapk.st.matrix.ServiceDepFactory +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.room.internal.DefaultRoomService +import app.dapk.st.matrix.room.internal.RoomMembers + +private val SERVICE_KEY = RoomService::class + +interface RoomService : MatrixService { + + suspend fun joinedMembers(roomId: RoomId): List + suspend fun markFullyRead(roomId: RoomId, eventId: EventId) + + suspend fun findMember(roomId: RoomId, userId: UserId): RoomMember? + suspend fun findMembers(roomId: RoomId, userIds: List): List + suspend fun insertMembers(roomId: RoomId, members: List) + + suspend fun createDm(userId: UserId, encrypted: Boolean): RoomId + + suspend fun joinRoom(roomId: RoomId) + + data class JoinedMember( + val userId: UserId, + val displayName: String, + val avatarUrl: String?, + ) + +} + +fun MatrixServiceInstaller.installRoomService( + memberStore: MemberStore, + roomMessenger: ServiceDepFactory, +) { + this.install { (httpClient, _, services, logger) -> + SERVICE_KEY to DefaultRoomService(httpClient, logger, RoomMembers(memberStore), roomMessenger.create(services)) + } +} + +fun MatrixServiceProvider.roomService(): RoomService = this.getService(key = SERVICE_KEY) + +interface MemberStore { + suspend fun insert(roomId: RoomId, members: List) + suspend fun query(roomId: RoomId, userIds: List): List +} + +interface RoomMessenger { + suspend fun enableEncryption(roomId: RoomId) +} \ No newline at end of file diff --git a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt new file mode 100644 index 0000000..686d9b2 --- /dev/null +++ b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt @@ -0,0 +1,125 @@ +package app.dapk.st.matrix.room.internal + +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.common.MatrixLogTag.ROOM +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.http.MatrixHttpClient.HttpRequest.Companion.httpRequest +import app.dapk.st.matrix.http.emptyJsonBody +import app.dapk.st.matrix.http.jsonBody +import app.dapk.st.matrix.room.RoomMessenger +import app.dapk.st.matrix.room.RoomService +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +class DefaultRoomService( + private val httpClient: MatrixHttpClient, + private val logger: MatrixLogger, + private val roomMembers: RoomMembers, + private val roomMessenger: RoomMessenger, +) : RoomService { + override suspend fun joinedMembers(roomId: RoomId): List { + val response = httpClient.execute(joinedMembersRequest(roomId)) + return response.joined.map { (userId, member) -> + RoomService.JoinedMember(userId, member.displayName, member.avatarUrl) + }.also { + logger.matrixLog(ROOM, "found members for $roomId : size: ${it.size}") + } + } + + override suspend fun markFullyRead(roomId: RoomId, eventId: EventId) { + logger.matrixLog(ROOM, "marking room fully read ${roomId.value}") + httpClient.execute(markFullyReadRequest(roomId, eventId)) + } + + override suspend fun findMember(roomId: RoomId, userId: UserId): RoomMember? { + return roomMembers.findMember(roomId, userId) + } + + override suspend fun findMembers(roomId: RoomId, userIds: List): List { + return roomMembers.findMembers(roomId, userIds) + } + + override suspend fun insertMembers(roomId: RoomId, members: List) { + roomMembers.insert(roomId, members) + } + + override suspend fun createDm(userId: UserId, encrypted: Boolean): RoomId { + logger.matrixLog("creating DM $userId") + val roomResponse = httpClient.execute( + createRoomRequest( + invites = listOf(userId), + isDM = true, + visibility = RoomVisibility.private + ) + ) + + if (encrypted) { + roomMessenger.enableEncryption(roomResponse.roomId) + } + return roomResponse.roomId + } + + override suspend fun joinRoom(roomId: RoomId) { + httpClient.execute(joinRoomRequest(roomId)) + } +} + +internal fun joinedMembersRequest(roomId: RoomId) = httpRequest( + path = "_matrix/client/r0/rooms/${roomId.value}/joined_members", + method = MatrixHttpClient.Method.GET, +) + +internal fun markFullyReadRequest(roomId: RoomId, eventId: EventId) = httpRequest( + path = "_matrix/client/r0/rooms/${roomId.value}/read_markers", + method = MatrixHttpClient.Method.POST, + body = jsonBody(MarkFullyReadRequest(eventId, eventId, hidden = true)) +) + +internal fun createRoomRequest(invites: List, isDM: Boolean, visibility: RoomVisibility, name: String? = null) = httpRequest( + path = "_matrix/client/r0/createRoom", + method = MatrixHttpClient.Method.POST, + body = jsonBody(CreateRoomRequest(invites, isDM, visibility, name)) +) + +internal fun joinRoomRequest(roomId: RoomId) = httpRequest( + path = "_matrix/client/r0/rooms/${roomId.value}/join", + method = MatrixHttpClient.Method.POST, + body = emptyJsonBody() +) + +@Suppress("EnumEntryName") +@Serializable +enum class RoomVisibility { + public, private +} + +@Serializable +internal data class CreateRoomRequest( + @SerialName("invite") val invites: List, + @SerialName("is_direct") val isDM: Boolean, + @SerialName("visibility") val visibility: RoomVisibility, + @SerialName("name") val name: String? = null, +) + +@Serializable +internal data class ApiCreateRoomResponse( + @SerialName("room_id") val roomId: RoomId, +) + +@Serializable +internal data class MarkFullyReadRequest( + @SerialName("m.fully_read") val eventId: EventId, + @SerialName("m.read") val read: EventId, + @SerialName("m.hidden") val hidden: Boolean +) + +@Serializable +internal data class JoinedMembersResponse( + @SerialName("joined") val joined: Map +) + +@Serializable +internal data class ApiJoinedMember( + @SerialName("display_name") val displayName: String, + @SerialName("avatar_url") val avatarUrl: String? = null, +) \ No newline at end of file diff --git a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/RoomMembers.kt b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/RoomMembers.kt new file mode 100644 index 0000000..ae156d2 --- /dev/null +++ b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/RoomMembers.kt @@ -0,0 +1,49 @@ +package app.dapk.st.matrix.room.internal + +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.room.MemberStore + +class RoomMembers(private val memberStore: MemberStore) { + + private val cache = mutableMapOf>() + + suspend fun findMember(roomId: RoomId, userId: UserId): RoomMember? { + return findMembers(roomId, listOf(userId)).firstOrNull() + } + + suspend fun findMembers(roomId: RoomId, userIds: List): List { + val roomCache = cache[roomId] + + return if (roomCache.isNullOrEmpty()) { + memberStore.query(roomId, userIds).also { cache(roomId, it) } + } else { + val (cachedMembers, missingIds) = userIds.fold(mutableListOf() to mutableListOf()) { acc, current -> + when (val member = roomCache[current]) { + null -> acc.second.add(current) + else -> acc.first.add(member) + } + acc + } + + when { + missingIds.isNotEmpty() -> { + (memberStore.query(roomId, missingIds).also { cache(roomId, it) } + cachedMembers) + } + else -> cachedMembers + } + } + } + + suspend fun insert(roomId: RoomId, members: List) { + cache(roomId, members) + memberStore.insert(roomId, members) + } + + private fun cache(roomId: RoomId, members: List) { + val map = cache.getOrPut(roomId) { mutableMapOf() } + members.forEach { map[it.id] = it } + } + +} \ No newline at end of file diff --git a/matrix/services/sync/build.gradle b/matrix/services/sync/build.gradle new file mode 100644 index 0000000..e0a4814 --- /dev/null +++ b/matrix/services/sync/build.gradle @@ -0,0 +1,13 @@ +plugins { id 'java-test-fixtures' } +applyMatrixServiceModule(project) + +dependencies { + implementation project(":core") + + kotlinTest(it) + kotlinFixtures(it) + testImplementation(testFixtures(project(":matrix:common"))) + testImplementation(testFixtures(project(":matrix:matrix-http"))) + testImplementation(testFixtures(project(":core"))) + testFixturesImplementation(testFixtures(project(":matrix:common"))) +} diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/OverviewState.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/OverviewState.kt new file mode 100644 index 0000000..b914799 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/OverviewState.kt @@ -0,0 +1,35 @@ +package app.dapk.st.matrix.sync + +import app.dapk.st.matrix.common.AvatarUrl +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.RoomMember +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +typealias OverviewState = List +typealias InviteState = List + +@Serializable +data class RoomOverview( + @SerialName("room_id") val roomId: RoomId, + @SerialName("room_creation_utc") val roomCreationUtc: Long, + @SerialName("room_name") val roomName: String?, + @SerialName("room_avatar") val roomAvatarUrl: AvatarUrl?, + @SerialName("last_message") val lastMessage: LastMessage?, + @SerialName("is_group") val isGroup: Boolean, + @SerialName("fully_read_marker") val readMarker: EventId?, + @SerialName("is_encrypted") val isEncrypted: Boolean, +) + +@Serializable +data class LastMessage( + @SerialName("content") val content: String, + @SerialName("timestamp") val utcTimestamp: Long, + @SerialName("author") val author: RoomMember, +) + +@Serializable +data class RoomInvite( + @SerialName("room_id") val roomId: RoomId, +) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/RoomState.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/RoomState.kt new file mode 100644 index 0000000..9eec44b --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/RoomState.kt @@ -0,0 +1,113 @@ +package app.dapk.st.matrix.sync + +import app.dapk.st.core.extensions.unsafeLazy +import app.dapk.st.matrix.common.* +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +data class RoomState( + val roomOverview: RoomOverview, + val events: List, +) + +internal val DEFAULT_ZONE = ZoneId.systemDefault() +internal val MESSAGE_TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm") + +@Serializable +sealed class RoomEvent { + + abstract val eventId: EventId + abstract val utcTimestamp: Long + + @Serializable + @SerialName("message") + data class Message( + @SerialName("event_id") override val eventId: EventId, + @SerialName("timestamp") override val utcTimestamp: Long, + @SerialName("content") val content: String, + @SerialName("author") val author: RoomMember, + @SerialName("meta") val meta: MessageMeta, + @SerialName("encrypted_content") val encryptedContent: MegOlmV1? = null, + @SerialName("edited") val edited: Boolean = false, + ) : RoomEvent() { + + @Serializable + data class MegOlmV1( + @SerialName("ciphertext") val cipherText: CipherText, + @SerialName("device_id") val deviceId: DeviceId, + @SerialName("sender_key") val senderKey: String, + @SerialName("session_id") val sessionId: SessionId, + ) + + @Transient + val time: String by unsafeLazy { + val instant = Instant.ofEpochMilli(utcTimestamp) + ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT) + } + } + + @Serializable + @SerialName("reply") + data class Reply( + @SerialName("message") val message: Message, + @SerialName("in_reply_to") val replyingTo: Message, + ) : RoomEvent() { + + override val eventId: EventId = message.eventId + override val utcTimestamp: Long = message.utcTimestamp + + val replyingToSelf = replyingTo.author == message.author + + @Transient + val time: String by unsafeLazy { + val instant = Instant.ofEpochMilli(utcTimestamp) + ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT) + } + } + +} + +@Serializable +sealed class MessageMeta { + + @Serializable + @SerialName("from_server") + object FromServer : MessageMeta() + + @Serializable + @SerialName("local_echo") + data class LocalEcho( + @SerialName("echo_id") val echoId: String, + @SerialName("state") val state: State + ) : MessageMeta() { + + @Serializable + sealed class State { + @Serializable + @SerialName("loading") + object Sending : State() + + @Serializable + @SerialName("success") + object Sent : State() + + @SerialName("error") + @Serializable + data class Error( + @SerialName("message") val message: String, + @SerialName("type") val type: Type, + ) : State() { + + @Serializable + enum class Type { + UNKNOWN + } + } + } + } +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/Store.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/Store.kt new file mode 100644 index 0000000..e0f123e --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/Store.kt @@ -0,0 +1,60 @@ +package app.dapk.st.matrix.sync + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.SyncToken +import kotlinx.coroutines.flow.Flow + +interface RoomStore { + + suspend fun persist(roomId: RoomId, state: RoomState) + suspend fun retrieve(roomId: RoomId): RoomState? + fun latest(roomId: RoomId): Flow + suspend fun insertUnread(roomId: RoomId, eventIds: List) + suspend fun markRead(roomId: RoomId) + suspend fun observeUnread(): Flow>> + suspend fun observeUnreadCountById(): Flow> + suspend fun observeEvent(eventId: EventId): Flow + suspend fun findEvent(eventId: EventId): RoomEvent? + +} + +interface FilterStore { + + suspend fun store(key: String, filterId: String) + + suspend fun read(key: String): String? +} + +interface OverviewStore { + + suspend fun persistInvites(invite: List) + suspend fun persist(overviewState: OverviewState) + + suspend fun retrieve(): OverviewState? + + fun latest(): Flow + fun latestInvites(): Flow> +} + +interface SyncStore { + + suspend fun store(key: SyncKey, syncToken: SyncToken) + suspend fun read(key: SyncKey): SyncToken? + suspend fun remove(key: SyncKey) + + sealed interface SyncKey { + + val value: String + + object Overview : SyncKey { + + override val value = "overview-sync-token" + } + + data class Room(val roomId: RoomId) : SyncKey { + + override val value = "room-sync-token-${roomId.value}" + } + } +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt new file mode 100644 index 0000000..eb8efba --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt @@ -0,0 +1,142 @@ +package app.dapk.st.matrix.sync + +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.matrix.MatrixClient +import app.dapk.st.matrix.MatrixService +import app.dapk.st.matrix.MatrixServiceInstaller +import app.dapk.st.matrix.ServiceDepFactory +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.sync.internal.DefaultSyncService +import app.dapk.st.matrix.sync.internal.SideEffectFlowIterator +import app.dapk.st.matrix.sync.internal.request.* +import app.dapk.st.matrix.sync.internal.room.MessageDecrypter +import app.dapk.st.matrix.sync.internal.room.MissingMessageDecrypter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +private val SERVICE_KEY = SyncService::class + +interface SyncService : MatrixService { + + suspend fun invites(): Flow + suspend fun overview(): Flow + suspend fun room(roomId: RoomId): Flow + suspend fun startSyncing(): Flow + suspend fun events(): Flow> + suspend fun observeEvent(eventId: EventId): Flow + suspend fun forceManualRefresh(roomIds: List) + + @JvmInline + value class FilterId(val value: String) + + sealed interface SyncEvent { + data class Typing(val roomId: RoomId, val members: List) : SyncEvent + } +} + +fun MatrixServiceInstaller.installSyncService( + credentialsStore: CredentialsStore, + overviewStore: OverviewStore, + roomStore: RoomStore, + syncStore: SyncStore, + filterStore: FilterStore, + deviceNotifier: ServiceDepFactory, + messageDecrypter: ServiceDepFactory = ServiceDepFactory { MissingMessageDecrypter }, + keySharer: ServiceDepFactory = ServiceDepFactory { NoOpKeySharer }, + verificationHandler: ServiceDepFactory = ServiceDepFactory { NoOpVerificationHandler }, + oneTimeKeyProducer: ServiceDepFactory, + roomMembersService: ServiceDepFactory, + errorTracker: ErrorTracker, + coroutineDispatchers: CoroutineDispatchers, + syncConfig: SyncConfig = SyncConfig(), +) { + this.serializers { + polymorphicDefault(ApiTimelineEvent::class) { + ApiTimelineEvent.Ignored.serializer() + } + polymorphicDefault(ApiToDeviceEvent::class) { + ApiToDeviceEvent.Ignored.serializer() + } + polymorphicDefault(ApiAccountEvent::class) { + ApiAccountEvent.Ignored.serializer() + } + polymorphicDefault(ApiEphemeralEvent::class) { + ApiEphemeralEvent.Ignored.serializer() + } + polymorphicDefault(ApiStrippedEvent::class) { + ApiStrippedEvent.Ignored.serializer() + } + polymorphicDefault(DecryptedContent::class) { + DecryptedContent.Ignored.serializer() + } + } + + this.install { (httpClient, json, services, logger) -> + SERVICE_KEY to DefaultSyncService( + httpClient = httpClient, + syncStore = syncStore, + overviewStore = overviewStore, + roomStore = roomStore, + filterStore = filterStore, + messageDecrypter = messageDecrypter.create(services), + keySharer = keySharer.create(services), + verificationHandler = verificationHandler.create(services), + deviceNotifier = deviceNotifier.create(services), + json = json, + oneTimeKeyProducer = oneTimeKeyProducer.create(services), + scope = CoroutineScope(coroutineDispatchers.io), + credentialsStore = credentialsStore, + roomMembersService = roomMembersService.create(services), + logger = logger, + errorTracker = errorTracker, + coroutineDispatchers = coroutineDispatchers, + syncConfig = syncConfig, + ) + } +} + +fun MatrixClient.syncService(): SyncService = this.getService(key = SERVICE_KEY) + +fun interface KeySharer { + suspend fun share(keys: List) +} + +fun interface VerificationHandler { + suspend fun handle(apiVerificationEvent: ApiToDeviceEvent.ApiVerificationEvent) +} + +internal object NoOpVerificationHandler : VerificationHandler { + override suspend fun handle(apiVerificationEvent: ApiToDeviceEvent.ApiVerificationEvent) { + // do nothing + } +} + +fun interface MaybeCreateMoreKeys { + suspend fun onServerKeyCount(count: ServerKeyCount) +} + + +fun interface DeviceNotifier { + suspend fun notifyChanges(userId: List, syncToken: SyncToken?) +} + +internal object NoOpKeySharer : KeySharer { + override suspend fun share(keys: List) { + // do nothing + } +} + +interface RoomMembersService { + suspend fun find(roomId: RoomId, userIds: List): List + suspend fun insert(roomId: RoomId, members: List) +} + +suspend fun RoomMembersService.find(roomId: RoomId, userId: UserId): RoomMember? { + return this.find(roomId, listOf(userId)).firstOrNull() +} + +data class SyncConfig( + val loopTimeout: Long = 30_000L, + val allowSharedFlows: Boolean = true +) \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt new file mode 100644 index 0000000..26d9885 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt @@ -0,0 +1,118 @@ +package app.dapk.st.matrix.sync.internal + +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.sync.* +import app.dapk.st.matrix.sync.internal.filter.FilterUseCase +import app.dapk.st.matrix.sync.internal.overview.ReducedSyncFilterUseCase +import app.dapk.st.matrix.sync.internal.room.MessageDecrypter +import app.dapk.st.matrix.sync.internal.room.RoomEventsDecrypter +import app.dapk.st.matrix.sync.internal.room.SyncEventDecrypter +import app.dapk.st.matrix.sync.internal.room.SyncSideEffects +import app.dapk.st.matrix.sync.internal.sync.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlinx.serialization.json.Json +import java.util.concurrent.atomic.AtomicInteger + +internal class DefaultSyncService( + httpClient: MatrixHttpClient, + syncStore: SyncStore, + private val overviewStore: OverviewStore, + private val roomStore: RoomStore, + filterStore: FilterStore, + messageDecrypter: MessageDecrypter, + keySharer: KeySharer, + verificationHandler: VerificationHandler, + deviceNotifier: DeviceNotifier, + json: Json, + oneTimeKeyProducer: MaybeCreateMoreKeys, + scope: CoroutineScope, + credentialsStore: CredentialsStore, + roomMembersService: RoomMembersService, + logger: MatrixLogger, + errorTracker: ErrorTracker, + coroutineDispatchers: CoroutineDispatchers, + syncConfig: SyncConfig, +) : SyncService { + + private val syncSubscriptionCount = AtomicInteger() + private val syncEventsFlow = MutableStateFlow>(emptyList()) + + private val roomDataSource by lazy { RoomDataSource(roomStore, logger) } + private val eventDecrypter by lazy { SyncEventDecrypter(messageDecrypter, json, logger) } + private val roomEventsDecrypter by lazy { RoomEventsDecrypter(messageDecrypter, json, logger) } + private val roomRefresher by lazy { RoomRefresher(roomDataSource, roomEventsDecrypter, logger) } + + private val sync2 by lazy { + val roomDataSource = RoomDataSource(roomStore, logger) + val syncReducer = SyncReducer( + RoomProcessor( + roomMembersService, + roomDataSource, + TimelineEventsProcessor( + RoomEventCreator(roomMembersService, logger, errorTracker), + roomEventsDecrypter, + eventDecrypter, + EventLookupUseCase(roomStore) + ), + RoomOverviewProcessor(roomMembersService), + UnreadEventsUseCase(roomStore, logger), + EphemeralEventsUseCase(roomMembersService, syncEventsFlow), + ), + roomRefresher, + logger, + coroutineDispatchers, + ) + SyncUseCase( + overviewStore, + SideEffectFlowIterator(logger), + SyncSideEffects(keySharer, verificationHandler, deviceNotifier, messageDecrypter, json, oneTimeKeyProducer, logger), + httpClient, + syncStore, + syncReducer, + credentialsStore, + logger, + ReducedSyncFilterUseCase(FilterUseCase(httpClient, filterStore)), + syncConfig, + ) + } + + private val syncFlow by lazy { + sync2.sync().let { + if (syncConfig.allowSharedFlows) { + it.shareIn(scope, SharingStarted.WhileSubscribed(5000)) + } else { + it + } + } + .onStart { + val subscriptions = syncSubscriptionCount.incrementAndGet() + logger.matrixLog(MatrixLogTag.SYNC, "flow onStart - count: $subscriptions") + } + .onCompletion { + val subscriptions = syncSubscriptionCount.decrementAndGet() + logger.matrixLog(MatrixLogTag.SYNC, "flow onCompletion - count: $subscriptions") + } + } + + override suspend fun startSyncing() = syncFlow + override suspend fun invites() = overviewStore.latestInvites() + override suspend fun overview() = overviewStore.latest() + override suspend fun room(roomId: RoomId) = roomStore.latest(roomId) + override suspend fun events() = syncEventsFlow + override suspend fun observeEvent(eventId: EventId) = roomStore.observeEvent(eventId) + override suspend fun forceManualRefresh(roomIds: List) { + withContext(Dispatchers.IO) { + roomIds.map { + async { + roomRefresher.refreshRoomContent(it)?.also { + overviewStore.persist(listOf(it.roomOverview)) + } + } + }.awaitAll() + } + } +} diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/FlowIterator.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/FlowIterator.kt new file mode 100644 index 0000000..14be144 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/FlowIterator.kt @@ -0,0 +1,26 @@ +package app.dapk.st.matrix.sync.internal + +import app.dapk.st.matrix.common.MatrixLogTag.SYNC +import app.dapk.st.matrix.common.MatrixLogger +import app.dapk.st.matrix.common.matrixLog +import kotlinx.coroutines.* + +internal class SideEffectFlowIterator(private val logger: MatrixLogger) { + suspend fun loop(initial: T?, action: suspend (T?) -> T?) { + var previousState = initial + + while (currentCoroutineContext().isActive) { + logger.matrixLog(SYNC, "loop iteration") + try { + previousState = withContext(NonCancellable) { + action(previousState) + } + } catch (error: Throwable) { + logger.matrixLog(SYNC, "on loop error: ${error.message}") + error.printStackTrace() + delay(10000L) + } + } + logger.matrixLog(SYNC, "isActive: ${currentCoroutineContext().isActive}") + } +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/filter/FilterRequest.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/filter/FilterRequest.kt new file mode 100644 index 0000000..3273284 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/filter/FilterRequest.kt @@ -0,0 +1,14 @@ +package app.dapk.st.matrix.sync.internal.filter + +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.http.MatrixHttpClient.HttpRequest.Companion.httpRequest +import app.dapk.st.matrix.http.jsonBody +import app.dapk.st.matrix.sync.internal.request.ApiFilterResponse +import app.dapk.st.matrix.sync.internal.request.FilterRequest + +internal fun filterRequest(userId: UserId, filterRequest: FilterRequest) = httpRequest( + path = "_matrix/client/r0/user/${userId.value}/filter", + method = MatrixHttpClient.Method.POST, + body = jsonBody(FilterRequest.serializer(), filterRequest), +) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/filter/FilterUseCase.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/filter/FilterUseCase.kt new file mode 100644 index 0000000..33988f2 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/filter/FilterUseCase.kt @@ -0,0 +1,24 @@ +package app.dapk.st.matrix.sync.internal.filter + +import app.dapk.st.core.extensions.ifNull +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.sync.FilterStore +import app.dapk.st.matrix.sync.SyncService +import app.dapk.st.matrix.sync.internal.request.FilterRequest + +internal class FilterUseCase( + private val client: MatrixHttpClient, + private val filterStore: FilterStore, +) { + + suspend fun filter(key: String, userId: UserId, filterRequest: FilterRequest): SyncService.FilterId { + val filterId = filterStore.read(key).ifNull { + client.execute(filterRequest(userId, filterRequest)).id.also { + filterStore.store(key, it) + } + } + return SyncService.FilterId(filterId) + } + +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/overview/ReducedSyncFilterUseCase.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/overview/ReducedSyncFilterUseCase.kt new file mode 100644 index 0000000..55c4bed --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/overview/ReducedSyncFilterUseCase.kt @@ -0,0 +1,39 @@ +package app.dapk.st.matrix.sync.internal.overview + +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.sync.SyncService.FilterId +import app.dapk.st.matrix.sync.internal.filter.FilterUseCase +import app.dapk.st.matrix.sync.internal.request.EventFilter +import app.dapk.st.matrix.sync.internal.request.FilterRequest +import app.dapk.st.matrix.sync.internal.request.RoomEventFilter +import app.dapk.st.matrix.sync.internal.request.RoomFilter + +private const val FIlTER_KEY = "reduced-filter-key" + +internal class ReducedSyncFilterUseCase( + private val filterUseCase: FilterUseCase, +) { + + suspend fun reducedFilter(userId: UserId): FilterId { + return filterUseCase.filter( + key = FIlTER_KEY, + userId = userId, + filterRequest = reduced() + ) + } + +} + +private fun reduced() = FilterRequest( + roomFilter = RoomFilter( + timelineFilter = RoomEventFilter( + lazyLoadMembers = true, + ), + stateFilter = RoomEventFilter( + lazyLoadMembers = true, + ), + ephemeralFilter = RoomEventFilter(types = listOf("m.typing")), + accountFilter = RoomEventFilter(types = listOf("m.fully_read")), + ), + account = EventFilter(types = listOf("m.direct")), +) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiFilterResponse.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiFilterResponse.kt new file mode 100644 index 0000000..0e61c41 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiFilterResponse.kt @@ -0,0 +1,41 @@ +package app.dapk.st.matrix.sync.internal.request + +import app.dapk.st.matrix.common.RoomId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class ApiFilterResponse( + @SerialName("filter_id") val id: String +) + +@Serializable +internal data class FilterRequest( + @SerialName("event_fields") val eventFields: List? = null, + @SerialName("room") val roomFilter: RoomFilter? = null, + @SerialName("account_data") val account: EventFilter? = null, +) + +@Serializable +internal data class RoomFilter( + @SerialName("rooms") val rooms: List? = null, + @SerialName("timeline") val timelineFilter: RoomEventFilter? = null, + @SerialName("state") val stateFilter: RoomEventFilter? = null, + @SerialName("ephemeral") val ephemeralFilter: RoomEventFilter? = null, + @SerialName("account_data") val accountFilter: RoomEventFilter? = null, +) + +@Serializable +internal data class RoomEventFilter( + @SerialName("limit") val limit: Int? = null, + @SerialName("types") val types: List? = null, + @SerialName("rooms") val rooms: List? = null, + @SerialName("lazy_load_members") val lazyLoadMembers: Boolean = false, +) + +@Serializable +internal data class EventFilter( + @SerialName("limit") val limit: Int? = null, + @SerialName("not_types") val notTypes: List? = null, + @SerialName("types") val types: List? = null, +) \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiSyncResponse.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiSyncResponse.kt new file mode 100644 index 0000000..f068675 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiSyncResponse.kt @@ -0,0 +1,496 @@ +package app.dapk.st.matrix.sync.internal.request + +import app.dapk.st.matrix.common.* +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +@Serializable +internal data class ApiSyncResponse( + @SerialName("device_lists") val deviceLists: DeviceLists? = null, + @SerialName("account_data") val accountData: ApiAccountData? = null, + @SerialName("rooms") val rooms: ApiSyncRooms? = null, + @SerialName("to_device") val toDevice: ToDevice? = null, + @SerialName("device_one_time_keys_count") val oneTimeKeysCount: Map, + @SerialName("next_batch") val nextBatch: SyncToken, + @SerialName("prev_batch") val prevBatch: SyncToken? = null, +) + +@Serializable +data class ApiAccountData( + @SerialName("events") val events: List +) + +@Serializable +sealed class ApiAccountEvent { + + @Serializable + @SerialName("m.direct") + data class Direct( + @SerialName("content") val content: Map> + ) : ApiAccountEvent() + + @Serializable + @SerialName("m.fully_read") + data class FullyRead( + @SerialName("content") val content: Content, + ) : ApiAccountEvent() { + + @Serializable + data class Content( + @SerialName("event_id") val eventId: EventId, + ) + + } + + @Serializable + object Ignored : ApiAccountEvent() +} + +@Serializable +internal data class DeviceLists( + @SerialName("changed") val changed: List? = null +) + +@Serializable +internal data class ToDevice( + @SerialName("events") val events: List +) + +@Serializable +sealed class ApiToDeviceEvent { + + @Serializable + @SerialName("m.room.encrypted") + internal data class Encrypted( + @SerialName("sender") val senderId: UserId, + @SerialName("content") val content: ApiEncryptedContent, + ) : ApiToDeviceEvent() + + @Serializable + @SerialName("m.room_key") + data class RoomKey( + @SerialName("sender") val sender: UserId, + @SerialName("content") val content: Content, + ) : ApiToDeviceEvent() { + @Serializable + data class Content( + @SerialName("room_id") val roomId: RoomId, + @SerialName("algorithm") val algorithmName: AlgorithmName, + @SerialName("session_id") val sessionId: SessionId, + @SerialName("session_key") val sessionKey: String, + @SerialName("chain_index") val chainIndex: Long, + ) + } + + @Serializable + @SerialName("m.key.verification.request") + data class VerificationRequest( + @SerialName("content") val content: Content, + @SerialName("sender") val sender: UserId, + ) : ApiToDeviceEvent(), ApiVerificationEvent { + + @Serializable + data class Content( + @SerialName("from_device") val fromDevice: DeviceId, + @SerialName("methods") val methods: List, + @SerialName("timestamp") val timestampPosix: Long, + @SerialName("transaction_id") val transactionId: String, + ) + } + + @Serializable + @SerialName("m.key.verification.ready") + data class VerificationReady( + @SerialName("content") val content: Content, + @SerialName("sender") val sender: UserId, + ) : ApiToDeviceEvent(), ApiVerificationEvent { + + @Serializable + data class Content( + @SerialName("from_device") val fromDevice: DeviceId, + @SerialName("methods") val methods: List, + @SerialName("transaction_id") val transactionId: String, + ) + } + + @Serializable + @SerialName("m.key.verification.start") + data class VerificationStart( + @SerialName("content") val content: Content, + @SerialName("sender") val sender: UserId, + ) : ApiToDeviceEvent(), ApiVerificationEvent { + + @Serializable + data class Content( + @SerialName("from_device") val fromDevice: DeviceId, + @SerialName("method") val method: String, + @SerialName("key_agreement_protocols") val protocols: List, + @SerialName("hashes") val hashes: List, + @SerialName("message_authentication_codes") val codes: List, + @SerialName("short_authentication_string") val short: List, + @SerialName("transaction_id") val transactionId: String, + ) + } + + @Serializable + @SerialName("m.key.verification.accept") + data class VerificationAccept( + @SerialName("content") val content: Content, + @SerialName("sender") val sender: UserId, + ) : ApiToDeviceEvent(), ApiVerificationEvent { + + @Serializable + data class Content( + @SerialName("from_device") val fromDevice: DeviceId, + @SerialName("method") val method: String, + @SerialName("key_agreement_protocol") val protocol: String, + @SerialName("hash") val hash: String, + @SerialName("message_authentication_code") val code: String, + @SerialName("short_authentication_string") val short: List, + @SerialName("commitment") val commitment: String, + @SerialName("transaction_id") val transactionId: String, + ) + } + + @Serializable + @SerialName("m.key.verification.key") + data class VerificationKey( + @SerialName("content") val content: Content, + @SerialName("sender") val sender: UserId, + ) : ApiToDeviceEvent(), ApiVerificationEvent { + + @Serializable + data class Content( + @SerialName("transaction_id") val transactionId: String, + @SerialName("key") val key: String, + ) + } + + @Serializable + @SerialName("m.key.verification.mac") + data class VerificationMac( + @SerialName("content") val content: Content, + @SerialName("sender") val sender: UserId, + ) : ApiToDeviceEvent(), ApiVerificationEvent { + + @Serializable + data class Content( + @SerialName("transaction_id") val transactionId: String, + @SerialName("keys") val keys: String, + @SerialName("mac") val mac: Map, + ) + } + + @Serializable + @SerialName("m.key.verification.cancel") + data class VerificationCancel( + @SerialName("content") val content: Content, + ) : ApiToDeviceEvent(), ApiVerificationEvent { + + @Serializable + data class Content( + @SerialName("code") val code: String, + @SerialName("reason") val reason: String, + @SerialName("transaction_id") val transactionId: String, + ) + } + + @Serializable + object Ignored : ApiToDeviceEvent() + + + sealed interface ApiVerificationEvent +} + +@Serializable +internal data class ApiSyncRooms( + @SerialName("join") val join: Map? = null, + @SerialName("invite") val invite: Map? = null, +) + +@Serializable +internal data class ApiSyncRoomInvite( + @SerialName("invite_state") val state: ApiInviteEvents, +) + +@Serializable +internal data class ApiInviteEvents( + @SerialName("events") val events: List +) + +@Serializable +sealed class ApiStrippedEvent { + + @Serializable + @SerialName("m.room.create") + internal data class RoomCreate( + @SerialName("content") val content: Content, + ) : ApiStrippedEvent() { + + @Serializable + internal data class Content( + @SerialName("type") val type: String? = null + ) + } + + @Serializable + object Ignored : ApiStrippedEvent() +} + +@Serializable +internal data class ApiSyncRoom( + @SerialName("timeline") val timeline: ApiSyncRoomTimeline, + @SerialName("state") val state: ApiSyncRoomState, + @SerialName("account_data") val accountData: ApiAccountData? = null, + @SerialName("ephemeral") val ephemeral: ApiEphemeral? = null, +) + +@Serializable +internal data class ApiEphemeral( + @SerialName("events") val events: List +) + +@Serializable +internal sealed class ApiEphemeralEvent { + + @Serializable + @SerialName("m.typing") + internal data class Typing( + @SerialName("content") val content: Content, + ) : ApiEphemeralEvent() { + @Serializable + internal data class Content( + @SerialName("user_ids") val userIds: List + ) + } + + @Serializable + object Ignored : ApiEphemeralEvent() +} + + +@Serializable +internal data class ApiSyncRoomState( + @SerialName("events") val stateEvents: List, +) + +@Serializable +internal data class ApiSyncRoomTimeline( + @SerialName("events") val apiTimelineEvents: List, +) + + +@Serializable +internal sealed class DecryptedContent { + + @Serializable + @SerialName("m.room.message") + internal data class TimelineText( + @SerialName("content") val content: ApiTimelineEvent.TimelineText.Content, + ) : DecryptedContent() + + @Serializable + object Ignored : DecryptedContent() +} + + +@Serializable(with = EncryptedContentDeserializer::class) +internal sealed class ApiEncryptedContent { + @Serializable + data class OlmV1( + @SerialName("ciphertext") val cipherText: Map, + @SerialName("sender_key") val senderKey: Curve25519, + ) : ApiEncryptedContent() + + @Serializable + data class MegOlmV1( + @SerialName("ciphertext") val cipherText: CipherText, + @SerialName("device_id") val deviceId: DeviceId, + @SerialName("sender_key") val senderKey: String, + @SerialName("session_id") val sessionId: SessionId, + @SerialName("m.relates_to") val relation: ApiTimelineEvent.TimelineText.Relation? = null, + ) : ApiEncryptedContent() + + @Serializable + data class CipherTextInfo( + @SerialName("body") val body: CipherText, + @SerialName("type") val type: Int, + ) + + @Serializable + object Unknown : ApiEncryptedContent() +} + +@Serializable +internal sealed class ApiTimelineEvent { + + @Serializable + @SerialName("m.room.create") + internal data class RoomCreate( + @SerialName("event_id") val id: EventId, + @SerialName("origin_server_ts") val utcTimestamp: Long, + @SerialName("content") val content: Content, + ) : ApiTimelineEvent() { + + @Serializable + internal data class Content( + @SerialName("type") val type: String? = null + ) + } + + @Serializable + @SerialName("m.room.topic") + internal data class RoomTopic( + @SerialName("event_id") val id: EventId, + @SerialName("content") val content: Content, + ) : ApiTimelineEvent() { + + @Serializable + internal data class Content( + @SerialName("topic") val topic: String + ) + } + + @Serializable + @SerialName("m.room.name") + internal data class RoomName( + @SerialName("event_id") val id: EventId, + @SerialName("content") val content: Content, + ) : ApiTimelineEvent() { + + @Serializable + internal data class Content( + @SerialName("name") val name: String + ) + } + + + @Serializable + @SerialName("m.room.avatar") + internal data class RoomAvatar( + @SerialName("event_id") val id: EventId, + @SerialName("content") val content: Content, + ) : ApiTimelineEvent() { + + @Serializable + internal data class Content( + @SerialName("url") val url: MxUrl? = null + ) + } + + + @Serializable + @SerialName("m.room.member") + internal data class RoomMember( + @SerialName("event_id") val id: EventId, + @SerialName("content") val content: Content, + @SerialName("sender") val senderId: UserId, + ) : ApiTimelineEvent() { + + @Serializable + internal data class Content( + @SerialName("displayname") val displayName: String? = null, + @SerialName("membership") val membership: Membership, + @SerialName("avatar_url") val avatarUrl: MxUrl? = null, + ) { + + @JvmInline + @Serializable + value class Membership(val value: String) { + fun isJoin() = value == "join" + fun isInvite() = value == "invite" + } + + } + } + + @Serializable + internal data class DecryptionStatus( + @SerialName("is_verified") val isVerified: Boolean + ) + + @Serializable + @SerialName("m.room.message") + internal data class TimelineText( + @SerialName("event_id") val id: EventId, + @SerialName("sender") val senderId: UserId, + @SerialName("content") val content: Content, + @SerialName("origin_server_ts") val utcTimestamp: Long, + @SerialName("st.decryption_status") val decryptionStatus: DecryptionStatus? = null + ) : ApiTimelineEvent() { + + @Serializable + internal data class Content( + @SerialName("body") val body: String? = null, + @SerialName("formatted_body") val formattedBody: String? = null, + @SerialName("msgtype") val type: String? = null, + @SerialName("m.relates_to") val relation: Relation? = null, + ) + + @Serializable + data class Relation( + @SerialName("m.in_reply_to") val inReplyTo: InReplyTo? = null, + @SerialName("rel_type") val relationType: String? = null, + @SerialName("event_id") val eventId: EventId? = null + ) + + @Serializable + data class InReplyTo( + @SerialName("event_id") val eventId: EventId + ) + } + + + @Serializable + @SerialName("m.room.encryption") + data class Encryption( + @SerialName("content") val content: Content, + ) : ApiTimelineEvent() { + @Serializable + data class Content( + @SerialName("algorithm") val algorithm: AlgorithmName, + @SerialName("rotation_period_ms") val rotationMs: Long? = null, + @SerialName("rotation_period_msgs") val rotationMessages: Long? = null, + ) + } + + @Serializable + @SerialName("m.room.encrypted") + internal data class Encrypted( + @SerialName("sender") val senderId: UserId, + @SerialName("content") val encryptedContent: ApiEncryptedContent, + @SerialName("event_id") val eventId: EventId, + @SerialName("origin_server_ts") val utcTimestamp: Long, + ) : ApiTimelineEvent() + + @Serializable + object Ignored : ApiTimelineEvent() +} + + +internal object EncryptedContentDeserializer : KSerializer { + + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("encryptedContent") + + override fun deserialize(decoder: Decoder): ApiEncryptedContent { + require(decoder is JsonDecoder) + val element = decoder.decodeJsonElement() + return when (val algorithm = element.jsonObject["algorithm"]?.jsonPrimitive?.content) { + "m.olm.v1.curve25519-aes-sha2" -> ApiEncryptedContent.OlmV1.serializer().deserialize(decoder) + "m.megolm.v1.aes-sha2" -> ApiEncryptedContent.MegOlmV1.serializer().deserialize(decoder) + null -> ApiEncryptedContent.Unknown + else -> throw IllegalArgumentException("Unknown algorithm : $algorithm") + } + } + + override fun serialize(encoder: Encoder, value: ApiEncryptedContent) = TODO("Not yet implemented") + +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/SyncRequest.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/SyncRequest.kt new file mode 100644 index 0000000..3f2f1fb --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/SyncRequest.kt @@ -0,0 +1,19 @@ +package app.dapk.st.matrix.sync.internal.request + +import app.dapk.st.matrix.common.SyncToken +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.http.MatrixHttpClient.HttpRequest.Companion.httpRequest +import app.dapk.st.matrix.http.queryMap +import app.dapk.st.matrix.sync.SyncService.FilterId + +internal fun syncRequest(lastSyncToken: SyncToken?, filterId: FilterId?, timeoutMs: Long) = + httpRequest( + path = "_matrix/client/r0/sync?${ + queryMap( + "since" to lastSyncToken?.value, + "filter" to filterId?.value, + "timeout" to timeoutMs.toString(), + ) + }", + method = MatrixHttpClient.Method.GET, + ) \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypter.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypter.kt new file mode 100644 index 0000000..83d9bad --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypter.kt @@ -0,0 +1,49 @@ +package app.dapk.st.matrix.sync.internal.room + +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.internal.request.DecryptedContent +import kotlinx.serialization.json.Json + +internal class RoomEventsDecrypter( + private val messageDecrypter: MessageDecrypter, + private val json: Json, + private val logger: MatrixLogger, +) { + + suspend fun decryptRoomEvents(events: List) = events.map { event -> + when (event) { + is RoomEvent.Message -> event.decrypt() + is RoomEvent.Reply -> RoomEvent.Reply( + message = event.message.decrypt(), + replyingTo = event.replyingTo.decrypt(), + ) + } + } + + private suspend fun RoomEvent.Message.decrypt() = when (this.encryptedContent) { + null -> this + else -> when (val result = messageDecrypter.decrypt(this.encryptedContent.toModel())) { + is DecryptionResult.Failed -> this.also { logger.crypto("Failed to decrypt ${it.eventId}") } + is DecryptionResult.Success -> when (val model = result.payload.toModel()) { + DecryptedContent.Ignored -> this + is DecryptedContent.TimelineText -> this.copyWithDecryptedContent(model) + } + } + } + + private fun JsonString.toModel() = json.decodeFromString(DecryptedContent.serializer(), this.value) + + private fun RoomEvent.Message.copyWithDecryptedContent(decryptedContent: DecryptedContent.TimelineText) = this.copy( + content = decryptedContent.content.body ?: "", + encryptedContent = null + ) + +} + +private fun RoomEvent.Message.MegOlmV1.toModel() = EncryptedMessageContent.MegOlmV1( + this.cipherText, + this.deviceId, + this.senderKey, + this.sessionId, +) \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomStateReducer.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomStateReducer.kt new file mode 100644 index 0000000..a92ebb8 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomStateReducer.kt @@ -0,0 +1,28 @@ +package app.dapk.st.matrix.sync.internal.room + +import app.dapk.st.matrix.common.DecryptionResult +import app.dapk.st.matrix.common.EncryptedMessageContent +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.sync.internal.request.ApiEncryptedContent + +internal fun ApiEncryptedContent.export(senderId: UserId): EncryptedMessageContent? { + return when (this) { + is ApiEncryptedContent.MegOlmV1 -> EncryptedMessageContent.MegOlmV1( + this.cipherText, this.deviceId, this.senderKey, this.sessionId + ) + is ApiEncryptedContent.OlmV1 -> EncryptedMessageContent.OlmV1( + senderId = senderId, + this.cipherText.mapValues { EncryptedMessageContent.CipherTextInfo(it.value.body, it.value.type) }, + this.senderKey + ) + ApiEncryptedContent.Unknown -> null + } +} + +fun interface MessageDecrypter { + suspend fun decrypt(event: EncryptedMessageContent): DecryptionResult +} + +internal object MissingMessageDecrypter : MessageDecrypter { + override suspend fun decrypt(event: EncryptedMessageContent) = throw IllegalStateException("No encrypter instance set") +} diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/SyncEventDecrypter.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/SyncEventDecrypter.kt new file mode 100644 index 0000000..50f0cb0 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/SyncEventDecrypter.kt @@ -0,0 +1,50 @@ +package app.dapk.st.matrix.sync.internal.room + +import app.dapk.st.matrix.common.DecryptionResult +import app.dapk.st.matrix.common.EncryptedMessageContent +import app.dapk.st.matrix.common.MatrixLogger +import app.dapk.st.matrix.common.matrixLog +import app.dapk.st.matrix.sync.internal.request.ApiEncryptedContent +import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent +import app.dapk.st.matrix.sync.internal.request.DecryptedContent +import kotlinx.serialization.json.Json + +internal class SyncEventDecrypter( + private val messageDecrypter: MessageDecrypter, + private val json: Json, + private val logger: MatrixLogger, +) { + + suspend fun decryptTimelineEvents(events: List) = events.map { event -> + when (event) { + is ApiTimelineEvent.Encrypted -> { + event.encryptedContent.export(event.senderId)?.let { encryptedContent -> + decrypt(encryptedContent, event) + } ?: event + } + else -> event + } + } + + private suspend fun decrypt(it: EncryptedMessageContent, event: ApiTimelineEvent.Encrypted) = messageDecrypter.decrypt(it).let { + when (it) { + is DecryptionResult.Failed -> event + is DecryptionResult.Success -> json.decodeFromString(DecryptedContent.serializer(), it.payload.value).let { + val relation = when (event.encryptedContent) { + is ApiEncryptedContent.MegOlmV1 -> event.encryptedContent.relation + is ApiEncryptedContent.OlmV1 -> null + ApiEncryptedContent.Unknown -> null + } + when (it) { + is DecryptedContent.TimelineText -> ApiTimelineEvent.TimelineText( + event.eventId, + event.senderId, + it.content.copy(relation = relation), + event.utcTimestamp, + ).also { logger.matrixLog("decrypted to timeline text: $it") } + DecryptedContent.Ignored -> event + } + } + } + } +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/SyncSideEffects.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/SyncSideEffects.kt new file mode 100644 index 0000000..288aab1 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/SyncSideEffects.kt @@ -0,0 +1,84 @@ +package app.dapk.st.matrix.sync.internal.room + +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.sync.DeviceNotifier +import app.dapk.st.matrix.sync.KeySharer +import app.dapk.st.matrix.sync.MaybeCreateMoreKeys +import app.dapk.st.matrix.sync.VerificationHandler +import app.dapk.st.matrix.sync.internal.request.ApiSyncResponse +import app.dapk.st.matrix.sync.internal.request.ApiToDeviceEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json + +internal class SyncSideEffects( + private val keySharer: KeySharer, + private val verificationHandler: VerificationHandler, + private val notifyDevicesUpdated: DeviceNotifier, + private val messageDecrypter: MessageDecrypter, + private val json: Json, + private val oneTimeKeyProducer: MaybeCreateMoreKeys, + private val logger: MatrixLogger, +) { + + suspend fun blockingSideEffects(userId: UserId, response: ApiSyncResponse, requestToken: SyncToken?): SideEffectResult { + return withContext(Dispatchers.IO) { + logger.matrixLog("process side effects") + response.deviceLists?.changed?.ifEmpty { null }?.let { + notifyDevicesUpdated.notifyChanges(it, requestToken) + } + oneTimeKeyProducer.onServerKeyCount(response.oneTimeKeysCount["signed_curve25519"] ?: ServerKeyCount(0)) + + val decryptedToDeviceEvents = decryptedToDeviceEvents(response) + val roomKeys = handleRoomKeyShares(decryptedToDeviceEvents) + + checkForVerificationRequests(userId, decryptedToDeviceEvents) + SideEffectResult(roomKeys?.map { it.roomId } ?: emptyList()) + } + } + + private suspend fun checkForVerificationRequests(selfId: UserId, toDeviceEvents: List?) { + toDeviceEvents?.filterIsInstance() + ?.ifEmpty { null } + ?.also { + if (it.size > 1) { + logger.matrixLog(MatrixLogTag.VERIFICATION, "found more verification events than expected, using first") + } + verificationHandler.handle(it.first()) + } + } + + private suspend fun handleRoomKeyShares(toDeviceEvents: List?): List? { + return toDeviceEvents?.filterIsInstance()?.map { + SharedRoomKey( + it.content.algorithmName, + it.content.roomId, + it.content.sessionId, + it.content.sessionKey, + isExported = false + ) + }?.also { keySharer.share(it) } + } + + private suspend fun decryptedToDeviceEvents(response: ApiSyncResponse) = response.toDevice?.events + ?.mapNotNull { + when (it) { + is ApiToDeviceEvent.Encrypted -> decryptEncryptedToDevice(it) + else -> it + } + } + + private suspend fun decryptEncryptedToDevice(it: ApiToDeviceEvent.Encrypted): ApiToDeviceEvent? { + logger.matrixLog("got encrypted toDevice event: from ${it.senderId}: $") + return it.content.export(it.senderId)?.let { + messageDecrypter.decrypt(it).let { + when (it) { + is DecryptionResult.Failed -> null + is DecryptionResult.Success -> json.decodeFromString(ApiToDeviceEvent.serializer(), it.payload.value) + } + } + } + } +} + +data class SideEffectResult(val roomsWithNewKeys: List) \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/EphemeralEventsUseCase.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/EphemeralEventsUseCase.kt new file mode 100644 index 0000000..52f4372 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/EphemeralEventsUseCase.kt @@ -0,0 +1,22 @@ +package app.dapk.st.matrix.sync.internal.sync + +import app.dapk.st.core.extensions.ifNotEmpty +import app.dapk.st.matrix.sync.RoomMembersService +import app.dapk.st.matrix.sync.SyncService +import app.dapk.st.matrix.sync.internal.request.ApiEphemeralEvent +import kotlinx.coroutines.flow.MutableSharedFlow + +internal class EphemeralEventsUseCase( + private val roomMembersService: RoomMembersService, + private val syncEventsFlow: MutableSharedFlow>, +) { + + suspend fun processEvents(roomToProcess: RoomToProcess) { + val syncEvents = roomToProcess.apiSyncRoom.ephemeral?.events?.filterIsInstance()?.map { + val members = it.content.userIds.ifNotEmpty { roomMembersService.find(roomToProcess.roomId, it) } + SyncService.SyncEvent.Typing(roomToProcess.roomId, members) + } + syncEvents?.let { syncEventsFlow.tryEmit(it) } + } + +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/EventLookupUseCase.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/EventLookupUseCase.kt new file mode 100644 index 0000000..779addf --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/EventLookupUseCase.kt @@ -0,0 +1,30 @@ +package app.dapk.st.matrix.sync.internal.sync + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.sync.RoomStore +import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent + +internal class EventLookupUseCase( + private val roomStore: RoomStore, +) { + + suspend fun lookup(eventId: EventId, decryptedTimeline: DecryptedTimeline, decryptedPreviousEvents: DecryptedRoomEvents): LookupResult { + return decryptedTimeline.lookup(eventId) + ?: decryptedPreviousEvents.lookup(eventId) + ?: lookupFromPersistence(eventId) + ?: LookupResult(apiTimelineEvent = null, roomEvent = null) + } + + private fun DecryptedTimeline.lookup(id: EventId) = this.value + .filterIsInstance() + .firstOrNull { it.id == id } + ?.let { LookupResult(apiTimelineEvent = it, roomEvent = null) } + + private fun DecryptedRoomEvents.lookup(id: EventId) = this.value + .firstOrNull { it.eventId == id } + ?.let { LookupResult(apiTimelineEvent = null, roomEvent = it) } + + private suspend fun lookupFromPersistence(eventId: EventId) = roomStore.findEvent(eventId)?.let { + LookupResult(apiTimelineEvent = null, roomEvent = it) + } +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/LookupResult.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/LookupResult.kt new file mode 100644 index 0000000..e4cbe6d --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/LookupResult.kt @@ -0,0 +1,22 @@ +package app.dapk.st.matrix.sync.internal.sync + +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent + +internal data class LookupResult( + private val apiTimelineEvent: ApiTimelineEvent.TimelineText?, + private val roomEvent: RoomEvent?, +) { + + inline fun fold( + onApiTimelineEvent: (ApiTimelineEvent.TimelineText) -> T?, + onRoomEvent: (RoomEvent) -> T?, + onEmpty: () -> T?, + ): T? { + return when { + apiTimelineEvent != null -> onApiTimelineEvent(apiTimelineEvent) + roomEvent != null -> onRoomEvent(roomEvent) + else -> onEmpty() + } + } +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomDataSource.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomDataSource.kt new file mode 100644 index 0000000..d58026b --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomDataSource.kt @@ -0,0 +1,30 @@ +package app.dapk.st.matrix.sync.internal.sync + +import app.dapk.st.matrix.common.MatrixLogTag +import app.dapk.st.matrix.common.MatrixLogger +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.matrixLog +import app.dapk.st.matrix.sync.RoomState +import app.dapk.st.matrix.sync.RoomStore + +class RoomDataSource( + private val roomStore: RoomStore, + private val logger: MatrixLogger, +) { + + private val roomCache = mutableMapOf() + + suspend fun read(roomId: RoomId) = when (val cached = roomCache[roomId]) { + null -> roomStore.retrieve(roomId)?.also { roomCache[roomId] = it } + else -> cached + } + + suspend fun persist(roomId: RoomId, previousState: RoomState?, newState: RoomState) { + if (newState == previousState) { + logger.matrixLog(MatrixLogTag.SYNC, "no changes, not persisting") + } else { + roomCache[roomId] = newState + roomStore.persist(roomId, newState) + } + } +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt new file mode 100644 index 0000000..8384c9c --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt @@ -0,0 +1,132 @@ +package app.dapk.st.matrix.sync.internal.sync + +import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.core.extensions.ifOrNull +import app.dapk.st.core.extensions.nullAndTrack +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.MatrixLogger +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.matrixLog +import app.dapk.st.matrix.sync.MessageMeta +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomMembersService +import app.dapk.st.matrix.sync.find +import app.dapk.st.matrix.sync.internal.request.ApiEncryptedContent +import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent + +private typealias Lookup = suspend (EventId) -> LookupResult + +internal class RoomEventCreator( + private val roomMembersService: RoomMembersService, + private val logger: MatrixLogger, + private val errorTracker: ErrorTracker, +) { + + suspend fun ApiTimelineEvent.Encrypted.toRoomEvent(roomId: RoomId): RoomEvent? { + return when (this.encryptedContent) { + is ApiEncryptedContent.MegOlmV1 -> { + RoomEvent.Message( + eventId = this.eventId, + author = roomMembersService.find(roomId, this.senderId)!!, + utcTimestamp = this.utcTimestamp, + meta = MessageMeta.FromServer, + content = "Encrypted message", + encryptedContent = RoomEvent.Message.MegOlmV1( + this.encryptedContent.cipherText, + this.encryptedContent.deviceId, + this.encryptedContent.senderKey, + this.encryptedContent.sessionId + ) + ) + } + is ApiEncryptedContent.OlmV1 -> errorTracker.nullAndTrack(IllegalStateException("unexpected encryption, got OlmV1 for a room event")) + ApiEncryptedContent.Unknown -> errorTracker.nullAndTrack(IllegalStateException("unknown room event encryption")) + } + } + + suspend fun ApiTimelineEvent.TimelineText.toRoomEvent(roomId: RoomId, lookup: Lookup): RoomEvent? { + return when { + this.isEdit() -> handleEdit(roomId, this.content.relation!!.eventId!!, lookup) + this.isReply() -> handleReply(roomId, lookup) + else -> this.toMessage(roomId) + } + } + + private suspend fun ApiTimelineEvent.TimelineText.handleEdit(roomId: RoomId, editedEventId: EventId, lookup: Lookup): RoomEvent? { + return lookup(editedEventId).fold( + onApiTimelineEvent = { + ifOrNull(this.utcTimestamp > it.utcTimestamp) { + it.toMessage( + roomId, + utcTimestamp = this.utcTimestamp, + content = this.content.body?.removePrefix(" * ")?.trim() ?: "redacted", + edited = true, + ) + } + }, + onRoomEvent = { + ifOrNull(this.utcTimestamp > it.utcTimestamp) { + when (it) { + is RoomEvent.Message -> it.edited(this) + is RoomEvent.Reply -> it.copy(message = it.message.edited(this)) + } + } + }, + onEmpty = { this.toMessage(roomId, edited = true) } + ) + } + + private fun RoomEvent.Message.edited(edit: ApiTimelineEvent.TimelineText) = this.copy( + content = edit.content.body?.removePrefix(" * ")?.trim() ?: "redacted", + utcTimestamp = edit.utcTimestamp, + edited = true, + ) + + private suspend fun ApiTimelineEvent.TimelineText.handleReply(roomId: RoomId, lookup: Lookup): RoomEvent { + val replyTo = this.content.relation!!.inReplyTo!! + + val relationEvent = lookup(replyTo.eventId).fold( + onApiTimelineEvent = { it.toMessage(roomId) }, + onRoomEvent = { it }, + onEmpty = { null } + ) + + logger.matrixLog("found relation: $relationEvent") + + return when (relationEvent) { + null -> this.toMessage(roomId) + else -> { + RoomEvent.Reply( + message = this.toMessage(roomId, content = this.content.formattedBody?.stripTags() ?: "redacted"), + replyingTo = when (relationEvent) { + is RoomEvent.Message -> relationEvent + is RoomEvent.Reply -> relationEvent.message + } + ) + } + } + } + + private suspend fun ApiTimelineEvent.TimelineText.toMessage( + roomId: RoomId, + content: String = this.content.body ?: "redacted", + edited: Boolean = false, + utcTimestamp: Long = this.utcTimestamp, + ) = RoomEvent.Message( + eventId = this.id, + content = content, + author = roomMembersService.find(roomId, this.senderId)!!, + utcTimestamp = utcTimestamp, + meta = MessageMeta.FromServer, + edited = edited, + ) + +} + +private fun String.stripTags() = this.substring(this.indexOf("") + "".length) + .trim() + .replace("", "") + .replace("", "") + +private fun ApiTimelineEvent.TimelineText.isEdit() = this.content.relation?.relationType == "m.replace" && this.content.relation.eventId != null +private fun ApiTimelineEvent.TimelineText.isReply() = this.content.relation?.inReplyTo != null \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomOverviewProcessor.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomOverviewProcessor.kt new file mode 100644 index 0000000..22d2061 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomOverviewProcessor.kt @@ -0,0 +1,81 @@ +package app.dapk.st.matrix.sync.internal.sync + +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.sync.LastMessage +import app.dapk.st.matrix.sync.RoomMembersService +import app.dapk.st.matrix.sync.RoomOverview +import app.dapk.st.matrix.sync.find +import app.dapk.st.matrix.sync.internal.request.ApiAccountEvent +import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent + +internal class RoomOverviewProcessor( + private val roomMembersService: RoomMembersService, +) { + + suspend fun process(roomToProcess: RoomToProcess, previousState: RoomOverview?, lastMessage: LastMessage?): RoomOverview { + val combinedEvents = roomToProcess.apiSyncRoom.state.stateEvents + roomToProcess.apiSyncRoom.timeline.apiTimelineEvents + val isEncrypted = combinedEvents.any { it is ApiTimelineEvent.Encryption } + + val readMarker = roomToProcess.apiSyncRoom.accountData?.events?.filterIsInstance()?.firstOrNull()?.content?.eventId + return when (previousState) { + null -> combinedEvents.filterIsInstance().first().let { roomCreate -> + val roomName = roomDisplayName(combinedEvents) + val isGroup = roomToProcess.directMessage == null + RoomOverview( + roomName = roomName ?: roomToProcess.directMessage?.let { + roomMembersService.find(roomToProcess.roomId, it)?.let { it.displayName ?: it.id.value } + }, + roomCreationUtc = roomCreate.utcTimestamp, + lastMessage = lastMessage, + roomId = roomToProcess.roomId, + isGroup = isGroup, + roomAvatarUrl = roomAvatar( + roomToProcess.roomId, + roomMembersService, + roomToProcess.directMessage, + combinedEvents, + roomToProcess.userCredentials.homeServer + ), + readMarker = readMarker, + isEncrypted = isEncrypted, + ) + } + else -> { + previousState.copy( + roomName = previousState.roomName ?: roomDisplayName(combinedEvents), + lastMessage = lastMessage ?: previousState.lastMessage, + roomAvatarUrl = previousState.roomAvatarUrl ?: roomAvatar( + roomToProcess.roomId, + roomMembersService, + roomToProcess.directMessage, + combinedEvents, + roomToProcess.userCredentials.homeServer, + ), + readMarker = readMarker ?: previousState.readMarker, + isEncrypted = isEncrypted || previousState.isEncrypted + ) + } + } + } + + private fun roomDisplayName(combinedEvents: List): String? { + val roomName = combinedEvents.filterIsInstance().lastOrNull() + return roomName?.content?.name + } + + private suspend fun roomAvatar( + roomId: RoomId, + membersService: RoomMembersService, + dmUser: UserId?, + combinedEvents: List, + homeServerUrl: HomeServerUrl + ): AvatarUrl? { + return when (dmUser) { + null -> { + val filterIsInstance = combinedEvents.filterIsInstance() + filterIsInstance.lastOrNull()?.content?.url?.convertMxUrToUrl(homeServerUrl)?.let { AvatarUrl(it) } + } + else -> membersService.find(roomId, dmUser)?.avatarUrl + } + } +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomProcessor.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomProcessor.kt new file mode 100644 index 0000000..a20488e --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomProcessor.kt @@ -0,0 +1,73 @@ +package app.dapk.st.matrix.sync.internal.sync + +import app.dapk.st.matrix.common.UserCredentials +import app.dapk.st.matrix.common.AvatarUrl +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.common.convertMxUrToUrl +import app.dapk.st.matrix.sync.* +import app.dapk.st.matrix.sync.internal.request.ApiSyncRoom +import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent + +internal class RoomProcessor( + private val roomMembersService: RoomMembersService, + private val roomDataSource: RoomDataSource, + private val timelineEventsProcessor: TimelineEventsProcessor, + private val roomOverviewProcessor: RoomOverviewProcessor, + private val unreadEventsUseCase: UnreadEventsUseCase, + private val ephemeralEventsUseCase: EphemeralEventsUseCase, +) { + + suspend fun processRoom(roomToProcess: RoomToProcess, isInitialSync: Boolean): RoomState { + val members = roomToProcess.apiSyncRoom.collectMembers(roomToProcess.userCredentials) + roomMembersService.insert(roomToProcess.roomId, members) + + val previousState = roomDataSource.read(roomToProcess.roomId) + + val (newEvents, distinctEvents) = timelineEventsProcessor.process( + roomToProcess, + previousState?.events ?: emptyList(), + ) + + val overview = createRoomOverview(distinctEvents, roomToProcess, previousState) + unreadEventsUseCase.processUnreadState(overview, previousState?.roomOverview, newEvents, roomToProcess.userCredentials.userId, isInitialSync) + + return RoomState(overview, distinctEvents).also { + roomDataSource.persist(roomToProcess.roomId, previousState, it) + ephemeralEventsUseCase.processEvents(roomToProcess) + } + } + + private suspend fun createRoomOverview(distinctEvents: List, roomToProcess: RoomToProcess, previousState: RoomState?): RoomOverview { + val lastMessage = distinctEvents.sortedByDescending { it.utcTimestamp }.findLastMessage() + return roomOverviewProcessor.process(roomToProcess, previousState?.roomOverview, lastMessage) + } + +} + +private fun ApiSyncRoom.collectMembers(userCredentials: UserCredentials): List { + return (this.state.stateEvents + this.timeline.apiTimelineEvents) + .filterIsInstance() + .mapNotNull { + when { + it.content.membership.isJoin() -> { + RoomMember( + displayName = it.content.displayName, + id = it.senderId, + avatarUrl = it.content.avatarUrl?.convertMxUrToUrl(userCredentials.homeServer)?.let { AvatarUrl(it) }, + ) + } + else -> null + } + } +} + + +internal fun List.findLastMessage(): LastMessage? { + return this.filterIsInstance().firstOrNull()?.let { + LastMessage( + content = it.content, + utcTimestamp = it.utcTimestamp, + author = it.author, + ) + } +} diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresher.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresher.kt new file mode 100644 index 0000000..5529797 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresher.kt @@ -0,0 +1,35 @@ +package app.dapk.st.matrix.sync.internal.sync + +import app.dapk.st.matrix.common.MatrixLogTag +import app.dapk.st.matrix.common.MatrixLogger +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.matrixLog +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomState +import app.dapk.st.matrix.sync.internal.room.RoomEventsDecrypter + +internal class RoomRefresher( + private val roomDataSource: RoomDataSource, + private val roomEventsDecrypter: RoomEventsDecrypter, + private val logger: MatrixLogger +) { + + suspend fun refreshRoomContent(roomId: RoomId): RoomState? { + logger.matrixLog(MatrixLogTag.SYNC, "reducing side effect: $roomId") + return when (val previousState = roomDataSource.read(roomId)) { + null -> null.also { logger.matrixLog(MatrixLogTag.SYNC, "no previous state to update") } + else -> { + logger.matrixLog(MatrixLogTag.SYNC, "previous state updated") + val decryptedEvents = previousState.events.decryptEvents() + val lastMessage = decryptedEvents.sortedByDescending { it.utcTimestamp }.findLastMessage() + + previousState.copy(events = decryptedEvents, roomOverview = previousState.roomOverview.copy(lastMessage = lastMessage)).also { + roomDataSource.persist(roomId, previousState, it) + } + } + } + } + + private suspend fun List.decryptEvents() = roomEventsDecrypter.decryptRoomEvents(this) + +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomToProcess.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomToProcess.kt new file mode 100644 index 0000000..757663c --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomToProcess.kt @@ -0,0 +1,13 @@ +package app.dapk.st.matrix.sync.internal.sync + +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.UserCredentials +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.sync.internal.request.ApiSyncRoom + +internal data class RoomToProcess( + val roomId: RoomId, + val apiSyncRoom: ApiSyncRoom, + val directMessage: UserId?, + val userCredentials: UserCredentials, +) \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncReducer.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncReducer.kt new file mode 100644 index 0000000..24eebc9 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncReducer.kt @@ -0,0 +1,71 @@ +package app.dapk.st.matrix.sync.internal.sync + +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.withIoContextAsync +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.common.MatrixLogTag.SYNC +import app.dapk.st.matrix.sync.RoomInvite +import app.dapk.st.matrix.sync.RoomState +import app.dapk.st.matrix.sync.internal.request.ApiAccountEvent +import app.dapk.st.matrix.sync.internal.request.ApiSyncResponse +import app.dapk.st.matrix.sync.internal.request.ApiSyncRoom +import app.dapk.st.matrix.sync.internal.room.SideEffectResult +import kotlinx.coroutines.awaitAll + +internal class SyncReducer( + private val roomProcessor: RoomProcessor, + private val roomRefresher: RoomRefresher, + private val logger: MatrixLogger, + private val coroutineDispatchers: CoroutineDispatchers, +) { + + data class ReducerResult( + val roomState: List, + val invites: List + ) + + suspend fun reduce(isInitialSync: Boolean, sideEffects: SideEffectResult, response: ApiSyncResponse, userCredentials: UserCredentials): ReducerResult { + val directMessages = response.directMessages() + + val invites = response.rooms?.invite?.keys?.map { RoomInvite(it) } ?: emptyList() + val apiUpdatedRooms = response.rooms?.join?.keepRoomsWithChanges() + val apiRoomsToProcess = apiUpdatedRooms?.map { (roomId, apiRoom) -> + logger.matrixLog(SYNC, "reducing: $roomId") + coroutineDispatchers.withIoContextAsync { + roomProcessor.processRoom( + roomToProcess = RoomToProcess( + roomId = roomId, + apiSyncRoom = apiRoom, + directMessage = directMessages[roomId], + userCredentials = userCredentials, + ), + isInitialSync = isInitialSync + ) + } + } ?: emptyList() + + val roomsWithSideEffects = sideEffects.roomsToRefresh(alreadyHandledRooms = apiUpdatedRooms?.keys ?: emptySet()).map { roomId -> + coroutineDispatchers.withIoContextAsync { + roomRefresher.refreshRoomContent(roomId) + } + } + + return ReducerResult((apiRoomsToProcess + roomsWithSideEffects).awaitAll().filterNotNull(), invites) + } +} + +private fun Map.keepRoomsWithChanges() = this.filter { + it.value.state.stateEvents.isNotEmpty() || + it.value.timeline.apiTimelineEvents.isNotEmpty() || + it.value.accountData?.events?.isNotEmpty() == true || + it.value.ephemeral?.events?.isNotEmpty() == true +} + +private fun SideEffectResult.roomsToRefresh(alreadyHandledRooms: Set) = this.roomsWithNewKeys.filterNot { alreadyHandledRooms.contains(it) } + +private fun ApiSyncResponse.directMessages() = this.accountData?.events?.filterIsInstance()?.firstOrNull()?.let { + it.content.entries.fold(mutableMapOf()) { acc, current -> + current.value.forEach { roomId -> acc[roomId] = current.key } + acc + } +} ?: emptyMap() diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncUseCase.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncUseCase.kt new file mode 100644 index 0000000..d56e8a8 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncUseCase.kt @@ -0,0 +1,74 @@ +package app.dapk.st.matrix.sync.internal.sync + +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.common.MatrixLogTag.SYNC +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.sync.* +import app.dapk.st.matrix.sync.internal.SideEffectFlowIterator +import app.dapk.st.matrix.sync.internal.overview.ReducedSyncFilterUseCase +import app.dapk.st.matrix.sync.internal.request.syncRequest +import app.dapk.st.matrix.sync.internal.room.SyncSideEffects +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.flow + +internal class SyncUseCase( + private val persistence: OverviewStore, + private val flowIterator: SideEffectFlowIterator, + private val syncSideEffects: SyncSideEffects, + private val client: MatrixHttpClient, + private val syncStore: SyncStore, + private val syncReducer: SyncReducer, + private val credentialsStore: CredentialsStore, + private val logger: MatrixLogger, + private val filterUseCase: ReducedSyncFilterUseCase, + private val syncConfig: SyncConfig, +) { + + fun sync(): Flow { + return flow { + logger.matrixLog("flow instance: ${hashCode()}") + val credentials = credentialsStore.credentials()!! + val filterId = filterUseCase.reducedFilter(credentials.userId) + + with(flowIterator) { + loop(initial = null) { previousState -> + logger.matrixLog("looper : ${hashCode()}") + val syncToken = syncStore.read(key = SyncStore.SyncKey.Overview) + val response = doSyncRequest(filterId, syncToken) + logger.logP("sync processing") { + syncStore.store(key = SyncStore.SyncKey.Overview, syncToken = response.nextBatch) + val sideEffects = logger.logP("side effects processing") { + syncSideEffects.blockingSideEffects(credentials.userId, response, syncToken) + } + + val isInitialSync = syncToken == null + val nextState = logger.logP("reducing") { syncReducer.reduce(isInitialSync, sideEffects, response, credentials) } + val overview = nextState.roomState.map { it.roomOverview } + + if (nextState.invites.isNotEmpty()) { + persistence.persistInvites(nextState.invites) + } + + when { + previousState == overview -> previousState.also { logger.matrixLog(SYNC, "no changes, not persisting new state") } + overview.isNotEmpty() -> overview.also { persistence.persist(overview) } + else -> previousState.also { logger.matrixLog(SYNC, "nothing to do") } + } + } + } + } + }.cancellable() + } + + private suspend fun doSyncRequest(filterId: SyncService.FilterId, syncToken: SyncToken?) = logger.logP("sync api") { + client.execute( + syncRequest( + lastSyncToken = syncToken, + filterId = filterId, + timeoutMs = syncConfig.loopTimeout, + ) + ) + } + +} diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessor.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessor.kt new file mode 100644 index 0000000..e0ab144 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessor.kt @@ -0,0 +1,57 @@ +package app.dapk.st.matrix.sync.internal.sync + +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent +import app.dapk.st.matrix.sync.internal.room.RoomEventsDecrypter +import app.dapk.st.matrix.sync.internal.room.SyncEventDecrypter + +private typealias NewEvents = List +private typealias AllDistinctEvents = List + +internal class TimelineEventsProcessor( + private val roomEventCreator: RoomEventCreator, + private val roomEventsDecrypter: RoomEventsDecrypter, + private val eventDecrypter: SyncEventDecrypter, + private val eventLookupUseCase: EventLookupUseCase +) { + + suspend fun process(roomToProcess: RoomToProcess, previousEvents: List): Pair { + val newEvents = processNewEvents(roomToProcess, previousEvents) + return newEvents to (newEvents + previousEvents).distinctBy { it.eventId } + } + + private suspend fun processNewEvents(roomToProcess: RoomToProcess, previousEvents: List): List { + val decryptedTimeline = roomToProcess.apiSyncRoom.timeline.apiTimelineEvents.decryptEvents() + val decryptedPreviousEvents = previousEvents.decryptEvents() + + val newEvents = with(roomEventCreator) { + decryptedTimeline.value.mapNotNull { event -> + val roomEvent = when (event) { + is ApiTimelineEvent.Encrypted -> event.toRoomEvent(roomToProcess.roomId) + is ApiTimelineEvent.TimelineText -> event.toRoomEvent(roomToProcess.roomId) { eventId -> + eventLookupUseCase.lookup(eventId, decryptedTimeline, decryptedPreviousEvents) + } + is ApiTimelineEvent.Encryption -> null + is ApiTimelineEvent.RoomAvatar -> null + is ApiTimelineEvent.RoomCreate -> null + is ApiTimelineEvent.RoomMember -> null + is ApiTimelineEvent.RoomName -> null + is ApiTimelineEvent.RoomTopic -> null + ApiTimelineEvent.Ignored -> null + } + roomEvent + } + } + return newEvents + } + + private suspend fun List.decryptEvents() = DecryptedTimeline(eventDecrypter.decryptTimelineEvents(this)) + private suspend fun List.decryptEvents() = DecryptedRoomEvents(roomEventsDecrypter.decryptRoomEvents(this)) + +} + +@JvmInline +internal value class DecryptedTimeline(val value: List) + +@JvmInline +internal value class DecryptedRoomEvents(val value: List) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsUseCase.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsUseCase.kt new file mode 100644 index 0000000..9defc92 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsUseCase.kt @@ -0,0 +1,52 @@ +package app.dapk.st.matrix.sync.internal.sync + +import app.dapk.st.matrix.common.MatrixLogTag +import app.dapk.st.matrix.common.MatrixLogger +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.common.matrixLog +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomOverview +import app.dapk.st.matrix.sync.RoomStore + +internal class UnreadEventsUseCase( + private val roomStore: RoomStore, + private val logger: MatrixLogger, +) { + + suspend fun processUnreadState( + overview: RoomOverview, + previousState: RoomOverview?, + newEvents: List, + selfId: UserId, + isInitialSync: Boolean, + ) { + val areWeViewingRoom = false // TODO + + when { + isInitialSync -> { + // let's assume everything is read + } + previousState?.readMarker != overview.readMarker -> { + // assume the user has viewed the room + logger.matrixLog(MatrixLogTag.SYNC, "marking room read due to new read marker") + roomStore.markRead(overview.roomId) + } + areWeViewingRoom -> { + logger.matrixLog(MatrixLogTag.SYNC, "marking room read") + roomStore.markRead(overview.roomId) + } + newEvents.isNotEmpty() -> { + logger.matrixLog(MatrixLogTag.SYNC, "insert new unread events") + + val eventsFromOthers = newEvents.filterNot { + when (it) { + is RoomEvent.Message -> it.author.id == selfId + is RoomEvent.Reply -> it.message.author.id == selfId + } + }.map { it.eventId } + roomStore.insertUnread(overview.roomId, eventsFromOthers) + } + } + } + +} \ No newline at end of file diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/filter/FilterUseCaseTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/filter/FilterUseCaseTest.kt new file mode 100644 index 0000000..569fc36 --- /dev/null +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/filter/FilterUseCaseTest.kt @@ -0,0 +1,44 @@ +package app.dapk.st.matrix.sync.internal.filter + +import app.dapk.st.matrix.sync.SyncService +import app.dapk.st.matrix.sync.internal.request.ApiFilterResponse +import app.dapk.st.matrix.sync.internal.request.FilterRequest +import fake.FakeFilterStore +import fake.FakeMatrixHttpClient +import fixture.aUserId +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import test.expect + +private const val A_FILTER_KEY = "a-filter-key" +private const val A_FILTER_ID_VALUE = "a-filter-id" +private val A_FILTER_REQUEST = FilterRequest() + +internal class FilterUseCaseTest { + + private val fakeClient = FakeMatrixHttpClient() + private val fakeFilterStore = FakeFilterStore() + + private val filterUseCase = FilterUseCase(fakeClient, fakeFilterStore) + + @Test + fun `given cached filter then returns cached value`() = runTest { + fakeFilterStore.givenCachedFilter(A_FILTER_KEY, A_FILTER_ID_VALUE) + + val result = filterUseCase.filter(A_FILTER_KEY, aUserId(), A_FILTER_REQUEST) + + result shouldBeEqualTo SyncService.FilterId(A_FILTER_ID_VALUE) + } + + @Test + fun `given no cached filter then fetches upstream filter and caches id result`() = runTest { + fakeFilterStore.givenCachedFilter(A_FILTER_KEY, filterIdValue = null) + fakeFilterStore.expect { it.store(A_FILTER_KEY, A_FILTER_ID_VALUE) } + fakeClient.given(request = filterRequest(aUserId(), A_FILTER_REQUEST), response = ApiFilterResponse(A_FILTER_ID_VALUE)) + + val result = filterUseCase.filter(A_FILTER_KEY, aUserId(), A_FILTER_REQUEST) + + result shouldBeEqualTo SyncService.FilterId(A_FILTER_ID_VALUE) + } +} diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/EphemeralEventsUseCaseTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/EphemeralEventsUseCaseTest.kt new file mode 100644 index 0000000..80ce1d9 --- /dev/null +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/EphemeralEventsUseCaseTest.kt @@ -0,0 +1,73 @@ +package app.dapk.st.matrix.sync.internal.sync + +import app.dapk.st.matrix.sync.SyncService +import app.dapk.st.matrix.sync.internal.request.ApiEphemeral +import fake.FakeRoomMembersService +import fixture.aRoomId +import fixture.aRoomMember +import fixture.aUserCredentials +import internalfixture.anApiEphemeral +import internalfixture.anApiSyncRoom +import internalfixture.anEphemeralTypingEvent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import test.TestSharedFlow + +private val A_ROOM_ID = aRoomId() +private val A_ROOM_MEMBER = aRoomMember() + +internal class EphemeralEventsUseCaseTest { + + private val fakeRoomMembersService = FakeRoomMembersService() + private val testFlow = TestSharedFlow>() + + private val ephemeralEventsUseCase = EphemeralEventsUseCase(fakeRoomMembersService, testFlow) + + @Test + fun `given no ephemeral events to process then does nothing`() = runTest { + val roomToProcess = aRoomToProcess() + + ephemeralEventsUseCase.processEvents(roomToProcess) + + testFlow.assertNoValues() + } + + @Test + fun `given known member is typing then emits typing`() = runTest { + fakeRoomMembersService.givenMember(A_ROOM_ID, A_ROOM_MEMBER.id, A_ROOM_MEMBER) + val roomToProcess = aRoomToProcess(anApiEphemeral(listOf(anEphemeralTypingEvent(listOf(A_ROOM_MEMBER.id))))) + + ephemeralEventsUseCase.processEvents(roomToProcess) + + testFlow.assertValues( + listOf(SyncService.SyncEvent.Typing(A_ROOM_ID, listOf(A_ROOM_MEMBER))) + ) + } + + @Test + fun `given unknown member is typing then emits empty`() = runTest { + fakeRoomMembersService.givenMember(A_ROOM_ID, A_ROOM_MEMBER.id, null) + val roomToProcess = aRoomToProcess(anApiEphemeral(listOf(anEphemeralTypingEvent(listOf(A_ROOM_MEMBER.id))))) + + ephemeralEventsUseCase.processEvents(roomToProcess) + + testFlow.assertValues(listOf(SyncService.SyncEvent.Typing(A_ROOM_ID, emptyList()))) + } + + @Test + fun `given member stops typing then emits empty`() = runTest { + fakeRoomMembersService.givenNoMembers(A_ROOM_ID) + val roomToProcess = aRoomToProcess(anApiEphemeral(listOf(anEphemeralTypingEvent(userIds = emptyList())))) + + ephemeralEventsUseCase.processEvents(roomToProcess) + + testFlow.assertValues(listOf(SyncService.SyncEvent.Typing(A_ROOM_ID, emptyList()))) + } +} + +private fun aRoomToProcess(ephemeral: ApiEphemeral? = null) = RoomToProcess( + A_ROOM_ID, + anApiSyncRoom(ephemeral = ephemeral), + directMessage = null, + userCredentials = aUserCredentials(), +) diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/EventLookupUseCaseTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/EventLookupUseCaseTest.kt new file mode 100644 index 0000000..b837fb6 --- /dev/null +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/EventLookupUseCaseTest.kt @@ -0,0 +1,74 @@ +package app.dapk.st.matrix.sync.internal.sync + +import fake.FakeRoomStore +import fixture.aRoomMessageEvent +import fixture.anEventId +import internalfixture.aTimelineTextEventContent +import internalfixture.anApiTimelineTextEvent +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +private val AN_EVENT_ID = anEventId() +private val A_TIMELINE_EVENT = anApiTimelineTextEvent(AN_EVENT_ID, content = aTimelineTextEventContent(body = "timeline event")) +private val A_ROOM_EVENT = aRoomMessageEvent(AN_EVENT_ID, content = "previous room event") +private val A_PERSISTED_EVENT = aRoomMessageEvent(AN_EVENT_ID, content = "persisted event") + +class EventLookupUseCaseTest { + + private val fakeRoomStore = FakeRoomStore() + + private val eventLookupUseCase = EventLookupUseCase(fakeRoomStore) + + @Test + fun `given all lookup sources fail then returns null results`() = runTest { + fakeRoomStore.givenEvent(AN_EVENT_ID, result = null) + + val result = eventLookupUseCase.lookup( + AN_EVENT_ID, + DecryptedTimeline(emptyList()), + DecryptedRoomEvents(emptyList()) + ) + + result shouldBeEqualTo LookupResult(null, null) + } + + @Test + fun `when looking up event then prioritises timeline result first`() = runTest { + fakeRoomStore.givenEvent(AN_EVENT_ID, result = A_PERSISTED_EVENT) + + val result = eventLookupUseCase.lookup( + AN_EVENT_ID, + DecryptedTimeline(listOf(A_TIMELINE_EVENT)), + DecryptedRoomEvents(listOf(A_ROOM_EVENT)) + ) + + result shouldBeEqualTo LookupResult(apiTimelineEvent = A_TIMELINE_EVENT, null) + } + + @Test + fun `given no timeline event when looking up event then returns previous room result`() = runTest { + fakeRoomStore.givenEvent(AN_EVENT_ID, result = A_PERSISTED_EVENT) + + val result = eventLookupUseCase.lookup( + AN_EVENT_ID, + DecryptedTimeline(emptyList()), + DecryptedRoomEvents(listOf(A_ROOM_EVENT)) + ) + + result shouldBeEqualTo LookupResult(apiTimelineEvent = null, roomEvent = A_ROOM_EVENT) + } + + @Test + fun `given no timeline or previous room event when looking up event then returns persisted room result`() = runTest { + fakeRoomStore.givenEvent(AN_EVENT_ID, result = A_PERSISTED_EVENT) + + val result = eventLookupUseCase.lookup( + AN_EVENT_ID, + DecryptedTimeline(emptyList()), + DecryptedRoomEvents(emptyList()) + ) + + result shouldBeEqualTo LookupResult(apiTimelineEvent = null, roomEvent = A_PERSISTED_EVENT) + } +} \ No newline at end of file diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt new file mode 100644 index 0000000..14ae3f9 --- /dev/null +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt @@ -0,0 +1,329 @@ +package app.dapk.st.matrix.sync.internal.sync + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.internal.request.ApiEncryptedContent +import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent +import fake.FakeErrorTracker +import fake.FakeMatrixLogger +import fake.FakeRoomMembersService +import fixture.* +import internalfixture.* +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +private val A_ROOM_ID = aRoomId() +private val A_SENDER = aRoomMember() +private val EMPTY_LOOKUP = FakeLookup(LookupResult(apiTimelineEvent = null, roomEvent = null)) +private const val A_TEXT_EVENT_MESSAGE = "a text message" +private const val A_REPLY_EVENT_MESSAGE = "a reply to another message" +private val A_TEXT_EVENT = anApiTimelineTextEvent( + senderId = A_SENDER.id, + content = aTimelineTextEventContent(body = A_TEXT_EVENT_MESSAGE) +) +private val A_TEXT_EVENT_WITHOUT_CONTENT = anApiTimelineTextEvent( + senderId = A_SENDER.id, + content = aTimelineTextEventContent(body = null) +) + +internal class RoomEventCreatorTest { + + private val fakeRoomMembersService = FakeRoomMembersService() + + private val roomEventCreator = RoomEventCreator(fakeRoomMembersService, FakeMatrixLogger(), FakeErrorTracker()) + + @Test + fun `given Megolm encrypted event then maps to encrypted room message`() = runTest { + fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) + val megolmEvent = anEncryptedApiTimelineEvent(senderId = A_SENDER.id, encryptedContent = aMegolmApiEncryptedContent()) + + val result = with(roomEventCreator) { megolmEvent.toRoomEvent(A_ROOM_ID) } + + result shouldBeEqualTo aRoomMessageEvent( + eventId = megolmEvent.eventId, + utcTimestamp = megolmEvent.utcTimestamp, + content = "Encrypted message", + author = A_SENDER, + encryptedContent = megolmEvent.encryptedContent.toMegolm(), + ) + } + + @Test + fun `given Olm encrypted event then maps to null`() = runTest { + val olmEvent = anEncryptedApiTimelineEvent(encryptedContent = anOlmApiEncryptedContent()) + + val result = with(roomEventCreator) { olmEvent.toRoomEvent(A_ROOM_ID) } + + result shouldBeEqualTo null + } + + @Test + fun `given unknown encrypted event then maps to null`() = runTest { + val olmEvent = anEncryptedApiTimelineEvent(encryptedContent = anUnknownApiEncryptedContent()) + + val result = with(roomEventCreator) { olmEvent.toRoomEvent(A_ROOM_ID) } + + result shouldBeEqualTo null + } + + @Test + fun `given text event then maps to room message`() = runTest { + fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) + + val result = with(roomEventCreator) { A_TEXT_EVENT.toRoomEvent(A_ROOM_ID, EMPTY_LOOKUP) } + + result shouldBeEqualTo aRoomMessageEvent( + eventId = A_TEXT_EVENT.id, + utcTimestamp = A_TEXT_EVENT.utcTimestamp, + content = A_TEXT_EVENT_MESSAGE, + author = A_SENDER, + ) + } + + @Test + fun `given text event without body then maps to redacted room message`() = runTest { + fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) + + val result = with(roomEventCreator) { A_TEXT_EVENT_WITHOUT_CONTENT.toRoomEvent(A_ROOM_ID, EMPTY_LOOKUP) } + + result shouldBeEqualTo aRoomMessageEvent( + eventId = A_TEXT_EVENT_WITHOUT_CONTENT.id, + utcTimestamp = A_TEXT_EVENT_WITHOUT_CONTENT.utcTimestamp, + content = "redacted", + author = A_SENDER, + ) + } + + @Test + fun `given edited event with no relation then maps to new room message`() = runTest { + fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) + val editEvent = anApiTimelineTextEvent().toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE) + + val result = with(roomEventCreator) { editEvent.toRoomEvent(A_ROOM_ID, EMPTY_LOOKUP) } + + result shouldBeEqualTo aRoomMessageEvent( + eventId = editEvent.id, + utcTimestamp = editEvent.utcTimestamp, + content = editEvent.content.body!!, + author = A_SENDER, + edited = true + ) + } + + @Test + fun `given edited event which relates to a timeline event then updates existing message`() = runTest { + fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) + val originalMessage = anApiTimelineTextEvent(utcTimestamp = 0) + val editedMessage = originalMessage.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE) + val lookup = givenLookup(originalMessage) + + val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_ROOM_ID, lookup) } + + result shouldBeEqualTo aRoomMessageEvent( + eventId = originalMessage.id, + utcTimestamp = editedMessage.utcTimestamp, + content = A_TEXT_EVENT_MESSAGE, + author = A_SENDER, + edited = true + ) + } + + @Test + fun `given edited event which relates to a room event then updates existing message`() = runTest { + fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) + val originalMessage = aRoomMessageEvent() + val editedMessage = originalMessage.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE) + val lookup = givenLookup(originalMessage) + + val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_ROOM_ID, lookup) } + + result shouldBeEqualTo aRoomMessageEvent( + eventId = originalMessage.eventId, + utcTimestamp = editedMessage.utcTimestamp, + content = A_TEXT_EVENT_MESSAGE, + author = A_SENDER, + edited = true + ) + } + + @Test + fun `given edited event which relates to a room reply event then only updates message`() = runTest { + fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) + val originalMessage = aRoomReplyMessageEvent(message = aRoomMessageEvent()) + val editedMessage = originalMessage.message.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE) + val lookup = givenLookup(originalMessage) + + val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_ROOM_ID, lookup) } + + result shouldBeEqualTo aRoomReplyMessageEvent( + replyingTo = originalMessage.replyingTo, + message = aRoomMessageEvent( + eventId = originalMessage.eventId, + utcTimestamp = editedMessage.utcTimestamp, + content = A_TEXT_EVENT_MESSAGE, + author = A_SENDER, + edited = true + ), + ) + } + + @Test + fun `given edited event is older than related known timeline event then ignores edit`() = runTest { + val originalMessage = anApiTimelineTextEvent(utcTimestamp = 1000) + val editedMessage = originalMessage.toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE) + val lookup = givenLookup(originalMessage) + + val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_ROOM_ID, lookup) } + + result shouldBeEqualTo null + } + + @Test + fun `given edited event is older than related room event then ignores edit`() = runTest { + val originalMessage = aRoomMessageEvent(utcTimestamp = 1000) + val editedMessage = originalMessage.toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE) + val lookup = givenLookup(originalMessage) + + val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_ROOM_ID, lookup) } + + result shouldBeEqualTo null + } + + @Test + fun `given reply event with no relation then maps to new room message`() = runTest { + fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) + val replyEvent = anApiTimelineTextEvent().toReplyEvent(messageContent = A_TEXT_EVENT_MESSAGE) + + val result = with(roomEventCreator) { replyEvent.toRoomEvent(A_ROOM_ID, EMPTY_LOOKUP) } + + result shouldBeEqualTo aRoomMessageEvent( + eventId = replyEvent.id, + utcTimestamp = replyEvent.utcTimestamp, + content = "${replyEvent.content.body}", + author = A_SENDER, + ) + } + + @Test + fun `given reply event which relates to a timeline event then maps to reply`() = runTest { + fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) + val originalMessage = anApiTimelineTextEvent(content = aTimelineTextEventContent(body = "message being replied to")) + val replyMessage = originalMessage.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE) + val lookup = givenLookup(originalMessage) + + val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_ROOM_ID, lookup) } + + result shouldBeEqualTo aRoomReplyMessageEvent( + replyingTo = aRoomMessageEvent( + eventId = originalMessage.id, + utcTimestamp = originalMessage.utcTimestamp, + content = originalMessage.content.body!!, + author = A_SENDER, + ), + message = aRoomMessageEvent( + eventId = replyMessage.id, + utcTimestamp = replyMessage.utcTimestamp, + content = A_REPLY_EVENT_MESSAGE, + author = A_SENDER, + ), + ) + } + + @Test + fun `given reply event which relates to a room event then maps to reply`() = runTest { + fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) + val originalMessage = aRoomMessageEvent() + val replyMessage = originalMessage.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE) + val lookup = givenLookup(originalMessage) + + val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_ROOM_ID, lookup) } + + result shouldBeEqualTo aRoomReplyMessageEvent( + replyingTo = originalMessage, + message = aRoomMessageEvent( + eventId = replyMessage.id, + utcTimestamp = replyMessage.utcTimestamp, + content = A_REPLY_EVENT_MESSAGE, + author = A_SENDER, + ), + ) + } + + @Test + fun `given reply event which relates to another room reply event then maps to reply with the reply's message`() = runTest { + fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) + val originalMessage = aRoomReplyMessageEvent() + val replyMessage = originalMessage.message.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE) + val lookup = givenLookup(originalMessage) + + val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_ROOM_ID, lookup) } + + result shouldBeEqualTo aRoomReplyMessageEvent( + replyingTo = originalMessage.message, + message = aRoomMessageEvent( + eventId = replyMessage.id, + utcTimestamp = replyMessage.utcTimestamp, + content = A_REPLY_EVENT_MESSAGE, + author = A_SENDER, + ), + ) + } + + private fun givenLookup(event: ApiTimelineEvent.TimelineText): suspend (EventId) -> LookupResult { + return { + if (it == event.id) LookupResult(event, roomEvent = null) else throw IllegalArgumentException("unexpected id: $it") + } + } + + private fun givenLookup(event: RoomEvent): suspend (EventId) -> LookupResult { + return { + if (it == event.eventId) LookupResult(apiTimelineEvent = null, roomEvent = event) else throw IllegalArgumentException("unexpected id: $it") + } + } +} + +private fun ApiTimelineEvent.TimelineText.toEditEvent(newTimestamp: Long, messageContent: String) = this.copy( + id = anEventId("a-new-event-id"), + utcTimestamp = newTimestamp, + content = aTimelineTextEventContent( + body = " * $messageContent", + relation = anEditRelation(this.id), + ) +) + +private fun RoomEvent.Message.toEditEvent(newTimestamp: Long, messageContent: String) = anApiTimelineTextEvent( + id = anEventId("a-new-event-id"), + utcTimestamp = newTimestamp, + content = aTimelineTextEventContent( + body = " * $messageContent", + relation = anEditRelation(this.eventId), + ) +) + +private fun ApiTimelineEvent.TimelineText.toReplyEvent(messageContent: String) = anApiTimelineTextEvent( + id = anEventId("a-new-event-id"), + content = aTimelineTextEventContent( + body = "${this.content} $messageContent", + formattedBody = "${this.content}$messageContent", + relation = aReplyRelation(this.id), + ) +) + +private fun RoomEvent.Message.toReplyEvent(messageContent: String) = anApiTimelineTextEvent( + id = anEventId("a-new-event-id"), + content = aTimelineTextEventContent( + body = "${this.content} $messageContent", + formattedBody = "${this.content}$messageContent", + relation = aReplyRelation(this.eventId), + ) +) + +private fun ApiEncryptedContent.toMegolm(): RoomEvent.Message.MegOlmV1 { + require(this is ApiEncryptedContent.MegOlmV1) + return aMegolmV1(this.cipherText, this.deviceId, this.senderKey, this.sessionId) +} + +private class FakeLookup(private val result: LookupResult) : suspend (EventId) -> LookupResult { + override suspend fun invoke(p1: EventId) = result +} \ No newline at end of file diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresherTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresherTest.kt new file mode 100644 index 0000000..1b5019e --- /dev/null +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresherTest.kt @@ -0,0 +1,64 @@ +package app.dapk.st.matrix.sync.internal.sync + +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomState +import fake.FakeMatrixLogger +import fake.FakeRoomDataSource +import internalfake.FakeRoomEventsDecrypter +import fixture.* +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import test.expect + +private val A_ROOM_ID = aRoomId() + +private object ARoom { + val MESSAGE_EVENT = aRoomMessageEvent(utcTimestamp = 0) + val ENCRYPTED_EVENT = anEncryptedRoomMessageEvent(utcTimestamp = 1) + val DECRYPTED_EVENT = aRoomMessageEvent(utcTimestamp = 2) + val PREVIOUS_STATE = RoomState(aRoomOverview(), listOf(MESSAGE_EVENT, ENCRYPTED_EVENT)) + val DECRYPTED_EVENTS = listOf(MESSAGE_EVENT, DECRYPTED_EVENT) + val NEW_STATE = RoomState(aRoomOverview(lastMessage = DECRYPTED_EVENT.asLastMessage()), DECRYPTED_EVENTS) +} + +internal class RoomRefresherTest { + + private val fakeRoomDataSource = FakeRoomDataSource() + private val fakeRoomEventsDecrypter = FakeRoomEventsDecrypter() + + private val roomRefresher = RoomRefresher( + fakeRoomDataSource.instance, + fakeRoomEventsDecrypter.instance, + FakeMatrixLogger(), + ) + + @Test + fun `given no existing room when refreshing then does nothing`() = runTest { + fakeRoomDataSource.givenNoCachedRoom(A_ROOM_ID) + + val result = roomRefresher.refreshRoomContent(aRoomId()) + + result shouldBeEqualTo null + fakeRoomDataSource.verifyNoChanges() + } + + @Test + fun `given existing room when refreshing then processes existing state`() = runTest { + fakeRoomDataSource.expect { it.instance.persist(RoomId(any()), any(), any()) } + fakeRoomDataSource.givenRoom(A_ROOM_ID, ARoom.PREVIOUS_STATE) + fakeRoomEventsDecrypter.givenDecrypts(ARoom.PREVIOUS_STATE.events, ARoom.DECRYPTED_EVENTS) + + val result = roomRefresher.refreshRoomContent(aRoomId()) + + fakeRoomDataSource.verifyRoomUpdated(ARoom.PREVIOUS_STATE, ARoom.NEW_STATE) + result shouldBeEqualTo ARoom.NEW_STATE + } +} + +private fun RoomEvent.Message.asLastMessage() = aLastMessage( + this.content, + this.utcTimestamp, + this.author, +) diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessorTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessorTest.kt new file mode 100644 index 0000000..6eb1ae9 --- /dev/null +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessorTest.kt @@ -0,0 +1,96 @@ +package app.dapk.st.matrix.sync.internal.sync + +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.UserCredentials +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.internal.request.ApiSyncRoom +import fixture.* +import internalfake.FakeEventLookup +import internalfake.FakeRoomEventCreator +import internalfake.FakeRoomEventsDecrypter +import internalfake.FakeSyncEventDecrypter +import internalfixture.* +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +private val A_ROOM_ID = aRoomId() +private val ANY_LOOKUP_RESULT = LookupResult(anApiTimelineTextEvent(), roomEvent = null) +private val AN_ENCRYPTED_TIMELINE_EVENT = anEncryptedApiTimelineEvent() +private val A_TEXT_TIMELINE_EVENT = anApiTimelineTextEvent() +private val A_MESSAGE_ROOM_EVENT = aRoomMessageEvent(anEventId("a-message")) +private val AN_ENCRYPTED_ROOM_EVENT = anEncryptedRoomMessageEvent(anEventId("encrypted-message")) +private val A_LOOKUP_EVENT_ID = anEventId("lookup-id") + +class TimelineEventsProcessorTest { + + private val fakeRoomEventsDecrypter = FakeRoomEventsDecrypter() + private val fakeSyncEventDecrypter = FakeSyncEventDecrypter() + private val fakeRoomEventCreator = FakeRoomEventCreator() + private val fakeEventLookup = FakeEventLookup() + + private val timelineEventsProcessor = TimelineEventsProcessor( + fakeRoomEventCreator.instance, + fakeRoomEventsDecrypter.instance, + fakeSyncEventDecrypter.instance, + fakeEventLookup.instance, + ) + + @Test + fun `given a room with no events then returns empty`() = runTest { + val previousEvents = emptyList() + val roomToProcess = aRoomToProcess() + fakeRoomEventsDecrypter.givenDecrypts(previousEvents) + fakeSyncEventDecrypter.givenDecrypts(roomToProcess.apiSyncRoom.timeline.apiTimelineEvents) + + val result = timelineEventsProcessor.process(roomToProcess, previousEvents) + + result shouldBeEqualTo (emptyList() to emptyList()) + } + + @Test + fun `given encrypted and text timeline events when processing then maps to room events`() = runTest { + val previousEvents = listOf(aRoomMessageEvent(eventId = anEventId("previous-event"))) + val newTimelineEvents = listOf(AN_ENCRYPTED_TIMELINE_EVENT, A_TEXT_TIMELINE_EVENT) + val roomToProcess = aRoomToProcess(apiSyncRoom = anApiSyncRoom(anApiSyncRoomTimeline(newTimelineEvents))) + fakeRoomEventsDecrypter.givenDecrypts(previousEvents) + fakeSyncEventDecrypter.givenDecrypts(newTimelineEvents) + fakeEventLookup.givenLookup(A_LOOKUP_EVENT_ID, DecryptedTimeline(newTimelineEvents), DecryptedRoomEvents(previousEvents), ANY_LOOKUP_RESULT) + fakeRoomEventCreator.givenCreates(A_ROOM_ID, AN_ENCRYPTED_TIMELINE_EVENT, AN_ENCRYPTED_ROOM_EVENT) + fakeRoomEventCreator.givenCreatesUsingLookup(A_ROOM_ID, A_LOOKUP_EVENT_ID, A_TEXT_TIMELINE_EVENT, A_MESSAGE_ROOM_EVENT, ANY_LOOKUP_RESULT) + + val result = timelineEventsProcessor.process(roomToProcess, previousEvents) + + val expectedNewRoomEvents = listOf(AN_ENCRYPTED_ROOM_EVENT, A_MESSAGE_ROOM_EVENT) + result shouldBeEqualTo (expectedNewRoomEvents to expectedNewRoomEvents + previousEvents) + } + + @Test + fun `given unhandled timeline events when processing then ignores events`() = runTest { + val previousEvents = emptyList() + val newTimelineEvents = listOf( + anEncryptionApiTimelineEvent(), + aRoomAvatarApiTimelineEvent(), + aRoomCreateApiTimelineEvent(), + aRoomMemberApiTimelineEvent(), + aRoomNameApiTimelineEvent(), + aRoomTopicApiTimelineEvent(), + anIgnoredApiTimelineEvent() + ) + val roomToProcess = aRoomToProcess(apiSyncRoom = anApiSyncRoom(anApiSyncRoomTimeline(newTimelineEvents))) + fakeRoomEventsDecrypter.givenDecrypts(previousEvents) + fakeSyncEventDecrypter.givenDecrypts(newTimelineEvents) + + val result = timelineEventsProcessor.process(roomToProcess, previousEvents) + + result shouldBeEqualTo (emptyList() to emptyList()) + } +} + +internal fun aRoomToProcess( + roomId: RoomId = aRoomId(), + apiSyncRoom: ApiSyncRoom = anApiSyncRoom(), + directMessage: UserId? = null, + userCredentials: UserCredentials = aUserCredentials(), +) = RoomToProcess(roomId, apiSyncRoom, directMessage, userCredentials) diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsUseCaseTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsUseCaseTest.kt new file mode 100644 index 0000000..eafbcf7 --- /dev/null +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsUseCaseTest.kt @@ -0,0 +1,68 @@ +package app.dapk.st.matrix.sync.internal.sync + +import app.dapk.st.matrix.common.RoomId +import fake.FakeMatrixLogger +import fake.FakeRoomStore +import fixture.* +import kotlinx.coroutines.test.runTest +import org.junit.Test +import test.expect + +private val A_ROOM_OVERVIEW = aRoomOverview() +private val A_ROOM_MESSAGE_FROM_OTHER = aRoomMessageEvent( + eventId = anEventId("a-new-message-event"), + author = aRoomMember(id = aUserId("a-different-user")) +) + +internal class UnreadEventsUseCaseTest { + + private val fakeRoomStore = FakeRoomStore() + + private val unreadEventsUseCase = UnreadEventsUseCase( + fakeRoomStore, + FakeMatrixLogger() + ) + + @Test + fun `given initial sync when processing unread then does mark any events as unread`() = runTest { + unreadEventsUseCase.processUnreadState( + isInitialSync = true, + overview = aRoomOverview(), + previousState = null, + newEvents = emptyList(), + selfId = aUserId() + ) + + fakeRoomStore.verifyNoUnreadChanges() + } + + @Test + fun `given read marker has changed when processing unread then marks room read`() = runTest { + fakeRoomStore.expect { it.markRead(RoomId(any())) } + + unreadEventsUseCase.processUnreadState( + isInitialSync = false, + overview = A_ROOM_OVERVIEW.copy(readMarker = anEventId("an-updated-marker")), + previousState = A_ROOM_OVERVIEW, + newEvents = emptyList(), + selfId = aUserId() + ) + + fakeRoomStore.verifyRoomMarkedRead(A_ROOM_OVERVIEW.roomId) + } + + @Test + fun `given new events from other users when processing unread then inserts events as unread`() = runTest { + fakeRoomStore.expect { it.insertUnread(RoomId(any()), any()) } + + unreadEventsUseCase.processUnreadState( + isInitialSync = false, + overview = A_ROOM_OVERVIEW, + previousState = null, + newEvents = listOf(A_ROOM_MESSAGE_FROM_OTHER), + selfId = aUserId() + ) + + fakeRoomStore.verifyInsertsEvents(A_ROOM_OVERVIEW.roomId, listOf(A_ROOM_MESSAGE_FROM_OTHER.eventId)) + } +} diff --git a/matrix/services/sync/src/test/kotlin/internalfake/FakeEventLookup.kt b/matrix/services/sync/src/test/kotlin/internalfake/FakeEventLookup.kt new file mode 100644 index 0000000..84cb9bc --- /dev/null +++ b/matrix/services/sync/src/test/kotlin/internalfake/FakeEventLookup.kt @@ -0,0 +1,17 @@ +package internalfake + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.sync.internal.sync.DecryptedRoomEvents +import app.dapk.st.matrix.sync.internal.sync.DecryptedTimeline +import app.dapk.st.matrix.sync.internal.sync.EventLookupUseCase +import app.dapk.st.matrix.sync.internal.sync.LookupResult +import io.mockk.coEvery +import io.mockk.mockk + +internal class FakeEventLookup { + val instance = mockk() + + fun givenLookup(eventId: EventId, timeline: DecryptedTimeline, previousEvents: DecryptedRoomEvents, result: LookupResult) { + coEvery { instance.lookup(eventId, timeline, previousEvents) } returns result + } +} \ No newline at end of file diff --git a/matrix/services/sync/src/test/kotlin/internalfake/FakeRoomEventCreator.kt b/matrix/services/sync/src/test/kotlin/internalfake/FakeRoomEventCreator.kt new file mode 100644 index 0000000..9cc02ea --- /dev/null +++ b/matrix/services/sync/src/test/kotlin/internalfake/FakeRoomEventCreator.kt @@ -0,0 +1,33 @@ +package internalfake + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent +import app.dapk.st.matrix.sync.internal.sync.LookupResult +import app.dapk.st.matrix.sync.internal.sync.RoomEventCreator +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.runBlocking + +internal class FakeRoomEventCreator { + val instance = mockk() + + fun givenCreates(roomId: RoomId, event: ApiTimelineEvent.Encrypted, result: RoomEvent) { + coEvery { with(instance) { event.toRoomEvent(roomId) } } returns result + } + + fun givenCreatesUsingLookup(roomId: RoomId, eventIdToLookup: EventId, event: ApiTimelineEvent.TimelineText, result: RoomEvent, lookupResult: LookupResult) { + val slot = slot LookupResult>() + coEvery { with(instance) { event.toRoomEvent(roomId, capture(slot)) } } answers { + runBlocking { + if (slot.captured.invoke(eventIdToLookup) == lookupResult) { + result + } else { + throw IllegalStateException() + } + } + } + } +} \ No newline at end of file diff --git a/matrix/services/sync/src/test/kotlin/internalfake/FakeRoomEventsDecrypter.kt b/matrix/services/sync/src/test/kotlin/internalfake/FakeRoomEventsDecrypter.kt new file mode 100644 index 0000000..64e8769 --- /dev/null +++ b/matrix/services/sync/src/test/kotlin/internalfake/FakeRoomEventsDecrypter.kt @@ -0,0 +1,14 @@ +package internalfake + +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.internal.room.RoomEventsDecrypter +import io.mockk.coEvery +import io.mockk.mockk + +internal class FakeRoomEventsDecrypter { + val instance = mockk() + + fun givenDecrypts(previousEvents: List, result: List = previousEvents) { + coEvery { instance.decryptRoomEvents(previousEvents) } returns result + } +} \ No newline at end of file diff --git a/matrix/services/sync/src/test/kotlin/internalfake/FakeSyncEventDecrypter.kt b/matrix/services/sync/src/test/kotlin/internalfake/FakeSyncEventDecrypter.kt new file mode 100644 index 0000000..1006f76 --- /dev/null +++ b/matrix/services/sync/src/test/kotlin/internalfake/FakeSyncEventDecrypter.kt @@ -0,0 +1,14 @@ +package internalfake + +import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent +import app.dapk.st.matrix.sync.internal.room.SyncEventDecrypter +import io.mockk.coEvery +import io.mockk.mockk + +internal class FakeSyncEventDecrypter { + val instance = mockk() + + fun givenDecrypts(events: List, result: List = events) { + coEvery { instance.decryptTimelineEvents(events) } returns result + } +} \ No newline at end of file diff --git a/matrix/services/sync/src/test/kotlin/internalfixture/ApiSyncRoomFixture.kt b/matrix/services/sync/src/test/kotlin/internalfixture/ApiSyncRoomFixture.kt new file mode 100644 index 0000000..7c1fe23 --- /dev/null +++ b/matrix/services/sync/src/test/kotlin/internalfixture/ApiSyncRoomFixture.kt @@ -0,0 +1,120 @@ +package internalfixture + +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.sync.internal.request.* +import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent.RoomMember.Content.Membership +import fixture.* + +internal fun anApiSyncRoom( + timeline: ApiSyncRoomTimeline = anApiSyncRoomTimeline(), + state: ApiSyncRoomState = anApiSyncRoomState(), + accountData: ApiAccountData? = null, + ephemeral: ApiEphemeral? = null, +) = ApiSyncRoom(timeline, state, accountData, ephemeral) + +internal fun anApiSyncRoomTimeline( + apiTimelineEvents: List = emptyList(), +) = ApiSyncRoomTimeline(apiTimelineEvents) + +internal fun anApiSyncRoomState( + stateEvents: List = emptyList(), +) = ApiSyncRoomState(stateEvents) + +internal fun anApiEphemeral( + events: List = emptyList() +) = ApiEphemeral(events) + +internal fun anEphemeralTypingEvent( + userIds: List = emptyList(), +) = ApiEphemeralEvent.Typing(ApiEphemeralEvent.Typing.Content(userIds)) + +internal fun anApiTimelineTextEvent( + id: EventId = anEventId(), + senderId: UserId = aUserId(), + content: ApiTimelineEvent.TimelineText.Content = aTimelineTextEventContent(), + utcTimestamp: Long = 0L, + decryptionStatus: ApiTimelineEvent.DecryptionStatus? = null +) = ApiTimelineEvent.TimelineText(id, senderId, content, utcTimestamp, decryptionStatus) + +internal fun aTimelineTextEventContent( + body: String? = null, + formattedBody: String? = null, + type: String? = null, + relation: ApiTimelineEvent.TimelineText.Relation? = null, +) = ApiTimelineEvent.TimelineText.Content(body, formattedBody, type, relation) + +internal fun anEditRelation(originalId: EventId) = ApiTimelineEvent.TimelineText.Relation( + relationType = "m.replace", + inReplyTo = null, + eventId = originalId, +) + +internal fun aReplyRelation(originalId: EventId) = ApiTimelineEvent.TimelineText.Relation( + relationType = null, + eventId = null, + inReplyTo = ApiTimelineEvent.TimelineText.InReplyTo(originalId), +) + +internal fun anEncryptedApiTimelineEvent( + senderId: UserId = aUserId(), + encryptedContent: ApiEncryptedContent = aMegolmApiEncryptedContent(), + eventId: EventId = anEventId(), + utcTimestamp: Long = 0L, +) = ApiTimelineEvent.Encrypted(senderId, encryptedContent, eventId, utcTimestamp) + +internal fun anEncryptionApiTimelineEvent( + algorithm: AlgorithmName = AlgorithmName("an-algorithm"), + rotationMs: Long? = null, + rotationMessages: Long? = null, +) = ApiTimelineEvent.Encryption(ApiTimelineEvent.Encryption.Content(algorithm, rotationMs, rotationMessages)) + +internal fun aRoomAvatarApiTimelineEvent( + eventId: EventId = anEventId(), + url: MxUrl? = null +) = ApiTimelineEvent.RoomAvatar(eventId, ApiTimelineEvent.RoomAvatar.Content(url)) + +internal fun aRoomCreateApiTimelineEvent( + eventId: EventId = anEventId(), + utcTimestamp: Long = 0L, + type: String? = null +) = ApiTimelineEvent.RoomCreate(eventId, utcTimestamp, ApiTimelineEvent.RoomCreate.Content(type)) + +internal fun aRoomMemberApiTimelineEvent( + eventId: EventId = anEventId(), + senderId: UserId = aUserId(), + displayName: String? = null, + membership: Membership = Membership("join"), + avatarUrl: MxUrl? = null, +) = ApiTimelineEvent.RoomMember(eventId, ApiTimelineEvent.RoomMember.Content(displayName, membership, avatarUrl), senderId) + +internal fun aRoomNameApiTimelineEvent( + eventId: EventId = anEventId(), + name: String = "a-room-name" +) = ApiTimelineEvent.RoomName(eventId, ApiTimelineEvent.RoomName.Content(name)) + +internal fun aRoomTopicApiTimelineEvent( + eventId: EventId = anEventId(), + topic: String = "a-room-topic" +) = ApiTimelineEvent.RoomTopic(eventId, ApiTimelineEvent.RoomTopic.Content(topic)) + +internal fun anIgnoredApiTimelineEvent() = ApiTimelineEvent.Ignored + +internal fun aMegolmApiEncryptedContent( + cipherText: CipherText = aCipherText(), + deviceId: DeviceId = aDeviceId(), + senderKey: String = "a-sender-key", + sessionId: SessionId = aSessionId(), + relation: ApiTimelineEvent.TimelineText.Relation? = null, +) = ApiEncryptedContent.MegOlmV1(cipherText, deviceId, senderKey, sessionId, relation) + +internal fun anOlmApiEncryptedContent( + cipherText: Map = mapOf(aCurve25519() to aCipherTextInfo()), + senderKey: Curve25519 = aCurve25519(), +) = ApiEncryptedContent.OlmV1(cipherText, senderKey) + +internal fun aCipherTextInfo( + body: CipherText = aCipherText(), + type: Int = 0, +) = ApiEncryptedContent.CipherTextInfo(body, type) + +internal fun anUnknownApiEncryptedContent() = ApiEncryptedContent.Unknown \ No newline at end of file diff --git a/matrix/services/sync/src/testFixtures/kotlin/fake/FakeFilterStore.kt b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeFilterStore.kt new file mode 100644 index 0000000..b3558df --- /dev/null +++ b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeFilterStore.kt @@ -0,0 +1,13 @@ +package fake + +import app.dapk.st.matrix.sync.FilterStore +import io.mockk.coEvery +import io.mockk.mockk + +class FakeFilterStore : FilterStore by mockk() { + + fun givenCachedFilter(key: String, filterIdValue: String?) { + coEvery { read(key) } returns filterIdValue + } + +} diff --git a/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomDataSource.kt b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomDataSource.kt new file mode 100644 index 0000000..85d2de1 --- /dev/null +++ b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomDataSource.kt @@ -0,0 +1,28 @@ +package fake + +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.sync.RoomState +import app.dapk.st.matrix.sync.internal.sync.RoomDataSource +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk + +class FakeRoomDataSource { + val instance = mockk() + + fun givenNoCachedRoom(roomId: RoomId) { + coEvery { instance.read(roomId) } returns null + } + + fun givenRoom(roomId: RoomId, roomState: RoomState?) { + coEvery { instance.read(roomId) } returns roomState + } + + fun verifyNoChanges() { + coVerify(exactly = 0) { instance.persist(RoomId(any()), any(), any()) } + } + + fun verifyRoomUpdated(previousEvents: RoomState?, newState: RoomState) { + coVerify { instance.persist(newState.roomOverview.roomId, previousEvents, newState) } + } +} \ No newline at end of file diff --git a/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomMembersService.kt b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomMembersService.kt new file mode 100644 index 0000000..79def2e --- /dev/null +++ b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomMembersService.kt @@ -0,0 +1,19 @@ +package fake + +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.sync.RoomMembersService +import io.mockk.coEvery +import io.mockk.mockk + +class FakeRoomMembersService : RoomMembersService by mockk() { + + fun givenMember(roomId: RoomId, userId: UserId, roomMember: RoomMember?) { + coEvery { find(roomId, listOf(userId)) } returns (roomMember?.let { listOf(it) } ?: emptyList()) + } + + fun givenNoMembers(roomId: RoomId) { + coEvery { find(roomId, emptyList()) } returns emptyList() + } +} \ No newline at end of file diff --git a/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomStore.kt b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomStore.kt new file mode 100644 index 0000000..6324cf6 --- /dev/null +++ b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomStore.kt @@ -0,0 +1,30 @@ +package fake + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomStore +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk + +class FakeRoomStore : RoomStore by mockk() { + + fun verifyNoUnreadChanges() { + coVerify(exactly = 0) { insertUnread(RoomId(any()), any()) } + coVerify(exactly = 0) { markRead(RoomId(any())) } + } + + fun verifyRoomMarkedRead(roomId: RoomId) { + coVerify { markRead(roomId) } + } + + fun verifyInsertsEvents(roomId: RoomId, events: List) { + coVerify { insertUnread(roomId, events) } + } + + fun givenEvent(eventId: EventId, result: RoomEvent?) { + coEvery { findEvent(eventId) } returns result + } + +} \ No newline at end of file diff --git a/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomEventFixture.kt b/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomEventFixture.kt new file mode 100644 index 0000000..f8f8524 --- /dev/null +++ b/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomEventFixture.kt @@ -0,0 +1,37 @@ +package fixture + +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.sync.MessageMeta +import app.dapk.st.matrix.sync.RoomEvent + +fun aRoomMessageEvent( + eventId: EventId = anEventId(), + utcTimestamp: Long = 0L, + content: String = "message-content", + author: RoomMember = aRoomMember(), + meta: MessageMeta = MessageMeta.FromServer, + encryptedContent: RoomEvent.Message.MegOlmV1? = null, + edited: Boolean = false, +) = RoomEvent.Message(eventId, utcTimestamp, content, author, meta, encryptedContent, edited) + +fun aRoomReplyMessageEvent( + message: RoomEvent.Message = aRoomMessageEvent(), + replyingTo: RoomEvent.Message = aRoomMessageEvent(eventId = anEventId("in-reply-to-id")), +) = RoomEvent.Reply(message, replyingTo) + +fun anEncryptedRoomMessageEvent( + eventId: EventId = anEventId(), + utcTimestamp: Long = 0L, + content: String = "encrypted-content", + author: RoomMember = aRoomMember(), + meta: MessageMeta = MessageMeta.FromServer, + encryptedContent: RoomEvent.Message.MegOlmV1? = aMegolmV1(), + edited: Boolean = false, +) = RoomEvent.Message(eventId, utcTimestamp, content, author, meta, encryptedContent, edited) + +fun aMegolmV1( + cipherText: CipherText = CipherText("a-cipher"), + deviceId: DeviceId = aDeviceId(), + senderKey: String = "a-sender-key", + sessionId: SessionId = aSessionId(), +) = RoomEvent.Message.MegOlmV1(cipherText, deviceId, senderKey, sessionId) \ No newline at end of file diff --git a/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomOverviewFixture.kt b/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomOverviewFixture.kt new file mode 100644 index 0000000..ecc363c --- /dev/null +++ b/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomOverviewFixture.kt @@ -0,0 +1,25 @@ +package fixture + +import app.dapk.st.matrix.common.AvatarUrl +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.sync.LastMessage +import app.dapk.st.matrix.sync.RoomOverview + +fun aRoomOverview( + roomId: RoomId = aRoomId(), + roomCreationUtc: Long = 0L, + roomName: String? = null, + roomAvatarUrl: AvatarUrl? = null, + lastMessage: LastMessage? = null, + isGroup: Boolean = false, + readMarker: EventId? = null, + isEncrypted: Boolean = false, +) = RoomOverview(roomId, roomCreationUtc, roomName, roomAvatarUrl, lastMessage, isGroup, readMarker, isEncrypted) + +fun aLastMessage( + content: String = "last-message-content", + utcTimestamp: Long = 0L, + author: RoomMember = aRoomMember(), +) = LastMessage(content, utcTimestamp, author) \ No newline at end of file diff --git a/matrix/services/sync/src/testFixtures/kotlin/fixture/SyncServiceFixtures.kt b/matrix/services/sync/src/testFixtures/kotlin/fixture/SyncServiceFixtures.kt new file mode 100644 index 0000000..24b1cba --- /dev/null +++ b/matrix/services/sync/src/testFixtures/kotlin/fixture/SyncServiceFixtures.kt @@ -0,0 +1,2 @@ +package fixture + diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..148e64a --- /dev/null +++ b/settings.gradle @@ -0,0 +1,47 @@ +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + apply from: "dependencies.gradle" + repositories { + Dependencies._repositories.call(it) + } +} +rootProject.name = "SmallTalk" +include ':app' + +include ':design-library' + +include ':features:directory' +include ':features:login' +include ':features:home' +include ':features:settings' +include ':features:profile' +include ':features:notifications' +include ':features:messenger' +include ':features:navigator' +include ':features:verification' + +include ':domains:android:core' +include ':domains:android:imageloader' +include ':domains:android:work' +include ':domains:android:tracking' +include ':domains:android:push' +include ':domains:store' +include ':domains:olm-stub' +include ':domains:olm' + +include ':matrix:matrix' +include ':matrix:common' +include ':matrix:matrix-http' +include ':matrix:matrix-http-ktor' +include ':matrix:services:auth' +include ':matrix:services:sync' +include ':matrix:services:room' +include ':matrix:services:push' +include ':matrix:services:message' +include ':matrix:services:device' +include ':matrix:services:crypto' +include ':matrix:services:profile' + +include ':core' + +include ':test-harness' diff --git a/test-harness/build.gradle b/test-harness/build.gradle new file mode 100644 index 0000000..5312167 --- /dev/null +++ b/test-harness/build.gradle @@ -0,0 +1,35 @@ +plugins { + id 'kotlin' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +test { + useJUnitPlatform() +} + +dependencies { + kotlinTest(it) + testImplementation 'app.cash.turbine:turbine:0.7.0' + + testImplementation Dependencies.mavenCentral.kotlinSerializationJson + + testImplementation project(":core") + testImplementation project(":domains:store") + testImplementation project(":domains:olm") + + testImplementation project(":matrix:matrix") + testImplementation project(":matrix:matrix-http-ktor") + testImplementation project(":matrix:services:auth") + testImplementation project(":matrix:services:sync") + testImplementation project(":matrix:services:room") + testImplementation project(":matrix:services:push") + testImplementation project(":matrix:services:message") + testImplementation project(":matrix:services:device") + testImplementation project(":matrix:services:crypto") + + testImplementation rootProject.files("external/jolm.jar") + testImplementation 'org.json:json:20211205' + + testImplementation Dependencies.mavenCentral.ktorJava + testImplementation Dependencies.mavenCentral.sqldelightInMemory +} diff --git a/test-harness/src/test/kotlin/SmokeTest.kt b/test-harness/src/test/kotlin/SmokeTest.kt new file mode 100644 index 0000000..415ced9 --- /dev/null +++ b/test-harness/src/test/kotlin/SmokeTest.kt @@ -0,0 +1,172 @@ +import app.dapk.st.matrix.auth.authService +import app.dapk.st.matrix.common.HomeServerUrl +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.crypto.Verification +import app.dapk.st.matrix.crypto.cryptoService +import app.dapk.st.matrix.room.roomService +import app.dapk.st.matrix.sync.syncService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldHaveSize +import org.amshove.kluent.shouldNotBeEqualTo +import org.junit.jupiter.api.MethodOrderer +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestMethodOrder +import test.MatrixTestScope +import test.TestMatrix +import test.flowTest +import test.restoreLoginAndInitialSync +import java.util.* + +private const val TEST_SERVER_URL_REDIRECT = "http://localhost:8080/" +private const val HTTPS_TEST_SERVER_URL = "https://localhost:8480/" + +@TestMethodOrder(MethodOrderer.OrderAnnotation::class) +class SmokeTest { + + @Test + @Order(1) + fun `can register accounts`() = runTest { + SharedState._alice = createAndRegisterAccount() + SharedState._bob = createAndRegisterAccount() + } + + @Test + @Order(2) + fun `can login`() = runTest { + login(SharedState.alice) + login(SharedState.bob) + } + + @Test + @Order(3) + fun `can create and join rooms`() = flowTest { + val alice = TestMatrix(SharedState.alice, includeLogging = true).also { it.loginWithInitialSync() } + + val roomId = alice.client.roomService().createDm(SharedState.bob.roomMember.id, encrypted = true) + alice.client.syncService().startSyncing().collectAsync { + alice.expectRoom(roomId) + } + + val bob = TestMatrix(SharedState.bob, includeLogging = true).also { it.loginWithInitialSync() } + bob.client.syncService().startSyncing().collectAsync { + bob.expectInvite(roomId) + bob.client.roomService().joinRoom(roomId) + bob.expectRoom(roomId) + } + + SharedState._sharedRoom = roomId + } + + @Test + @Order(4) + fun `can send and receive encrypted messages`() = testAfterInitialSync { alice, bob -> + val message = "from alice to bob : ${System.currentTimeMillis()}".from(SharedState.alice.roomMember) + alice.sendEncryptedMessage(SharedState.sharedRoom, message.content) + bob.expectMessage(SharedState.sharedRoom, message) + + val message2 = "from bob to alice : ${System.currentTimeMillis()}".from(SharedState.bob.roomMember) + bob.sendEncryptedMessage(SharedState.sharedRoom, message2.content) + alice.expectMessage(SharedState.sharedRoom, message2) + + val aliceSecondDevice = TestMatrix(SharedState.alice).also { it.newlogin() } + aliceSecondDevice.client.syncService().startSyncing().collectAsync { + val message3 = "from alice to bob and alice's second device : ${System.currentTimeMillis()}".from(SharedState.alice.roomMember) + alice.sendEncryptedMessage(SharedState.sharedRoom, message3.content) + aliceSecondDevice.expectMessage(SharedState.sharedRoom, message3) + bob.expectMessage(SharedState.sharedRoom, message3) + + val message4 = "from alice's second device to bob and alice's first device : ${System.currentTimeMillis()}".from(SharedState.alice.roomMember) + aliceSecondDevice.sendEncryptedMessage(SharedState.sharedRoom, message4.content) + alice.expectMessage(SharedState.sharedRoom, message4) + bob.expectMessage(SharedState.sharedRoom, message4) + } + } + + @Test + @Order(5) + fun `can request and verify devices`() = testAfterInitialSync { alice, bob -> + alice.client.cryptoService().verificationAction(Verification.Action.Request(bob.userId(), bob.deviceId())) + alice.client.cryptoService().verificationState().automaticVerification(alice).expectAsync { it == Verification.State.Done } + bob.client.cryptoService().verificationState().automaticVerification(bob).expectAsync { it == Verification.State.Done } + + waitForExpects() + } + + @Test + fun `can import E2E room keys file`() = runTest { + val ignoredUser = TestUser("ignored", RoomMember(UserId("ignored"), null, null), "ignored") + val cryptoService = TestMatrix(ignoredUser, includeLogging = true).client.cryptoService() + val stream = Thread.currentThread().contextClassLoader.getResourceAsStream("element-keys.txt")!! + + val result = with(cryptoService) { + stream.importRoomKeys(password = "aaaaaa") + } + + result shouldBeEqualTo listOf(RoomId(value="!qOSENTtFUuCEKJSVzl:matrix.org")) + } +} + +private suspend fun createAndRegisterAccount(): TestUser { + val aUserName = "${UUID.randomUUID()}" + val userId = UserId("@$aUserName:localhost:8480") + val aUser = TestUser("aaaa11111zzzz", RoomMember(userId, aUserName, null), HTTPS_TEST_SERVER_URL) + + val result = TestMatrix(aUser, includeLogging = true, includeHttpLogging = true) + .client + .authService() + .register(aUserName, aUser.password, homeServer = HTTPS_TEST_SERVER_URL) + + result.accessToken shouldNotBeEqualTo null + result.homeServer shouldBeEqualTo HomeServerUrl(TEST_SERVER_URL_REDIRECT) + result.userId shouldBeEqualTo userId + return aUser +} + +private suspend fun login(user: TestUser) { + val testMatrix = TestMatrix(user, includeLogging = true) + val result = testMatrix + .client + .authService() + .login(userName = user.roomMember.id.value, password = user.password) + + result.accessToken shouldNotBeEqualTo null + result.homeServer shouldBeEqualTo HomeServerUrl(TEST_SERVER_URL_REDIRECT) + result.userId shouldBeEqualTo user.roomMember.id + + testMatrix.saveLogin(result) +} + +object SharedState { + val alice: TestUser + get() = _alice!! + var _alice: TestUser? = null + + val bob: TestUser + get() = _bob!! + var _bob: TestUser? = null + + val sharedRoom: RoomId + get() = _sharedRoom!! + var _sharedRoom: RoomId? = null +} + +data class TestUser(val password: String, val roomMember: RoomMember, val homeServer: String) +data class TestMessage(val content: String, val author: RoomMember) + +fun String.from(roomMember: RoomMember) = TestMessage(this, roomMember) + +fun testAfterInitialSync(block: suspend MatrixTestScope.(TestMatrix, TestMatrix) -> Unit) { + restoreLoginAndInitialSync(TestMatrix(SharedState.alice, includeLogging = false), TestMatrix(SharedState.bob, includeLogging = false), block) +} + +private fun Flow.automaticVerification(testMatrix: TestMatrix) = this.onEach { + when (it) { + is Verification.State.WaitingForMatchConfirmation -> testMatrix.client.cryptoService().verificationAction(Verification.Action.AcknowledgeMatch) + } +} \ No newline at end of file diff --git a/test-harness/src/test/kotlin/io/ktor/client/engine/java/AJava.kt b/test-harness/src/test/kotlin/io/ktor/client/engine/java/AJava.kt new file mode 100644 index 0000000..b08a5d8 --- /dev/null +++ b/test-harness/src/test/kotlin/io/ktor/client/engine/java/AJava.kt @@ -0,0 +1,45 @@ +package io.ktor.client.engine.java + +import io.ktor.client.* +import io.ktor.client.engine.* +import io.ktor.util.* +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.X509TrustManager + +/** + * Work around to disable SSL for local syanpse testing + * Ktor loads http engines by name, hence the JavaHttpEngineContainer name being 1 + */ +@OptIn(InternalAPI::class) +object TestJava : HttpClientEngineFactory { + init { + System.getProperties().setProperty("jdk.internal.httpclient.disableHostnameVerification", "true") + } + + override fun create(block: JavaHttpConfig.() -> Unit): HttpClientEngine { + val config = JavaHttpConfig().apply(block).also { + it.config { + val apply = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(TrustAllX509TrustManager()), SecureRandom()) + } + sslContext(apply) + } + } + return JavaHttpEngine(config) + } +} + +@Suppress("KDocMissingDocumentation") +class JavaHttpEngineContainer : HttpClientEngineContainer { + override val factory: HttpClientEngineFactory<*> = TestJava + + override fun toString(): String = "1" +} + +private class TrustAllX509TrustManager : X509TrustManager { + override fun getAcceptedIssuers(): Array? = null + override fun checkClientTrusted(certs: Array?, authType: String?) {} + override fun checkServerTrusted(certs: Array?, authType: String?) {} +} \ No newline at end of file diff --git a/test-harness/src/test/kotlin/test/Test.kt b/test-harness/src/test/kotlin/test/Test.kt new file mode 100644 index 0000000..f712d70 --- /dev/null +++ b/test-harness/src/test/kotlin/test/Test.kt @@ -0,0 +1,143 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package test + +import TestMessage +import app.dapk.st.core.extensions.ifNull +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.message.messageService +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.syncService +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.fail +import org.amshove.kluent.shouldBeEqualTo + +fun flowTest(block: suspend MatrixTestScope.() -> Unit) { + runTest { + val testScope = MatrixTestScope(this) + block(testScope) + } +} + +fun restoreLoginAndInitialSync(m1: TestMatrix, m2: TestMatrix, testBody: suspend MatrixTestScope.(TestMatrix, TestMatrix) -> Unit) { + runTest { + println("restore login 1") + m1.restoreLogin() + println("restore login 2") + m2.restoreLogin() + val testHelper = MatrixTestScope(this) + with(testHelper) { + combine(m1.client.syncService().startSyncing(), m2.client.syncService().startSyncing()) { _, _ -> }.collectAsync { + m1.client.syncService().overview().first() + m2.client.syncService().overview().first() + testBody(testHelper, m1, m2) + } + } + } +} + +suspend fun Flow.collectAsync(scope: CoroutineScope, block: suspend () -> Unit) { + val work = scope.async { + withContext(Dispatchers.IO) { + collect() + } + } + block() + work.cancelAndJoin() +} + +class MatrixTestScope(private val testScope: TestScope) { + + private val inProgressExpects = mutableListOf>() + + suspend fun Flow.collectAsync(block: suspend () -> Unit) { + collectAsync(testScope, block) + } + + suspend fun delay(amountMs: Long) { + withContext(Dispatchers.Unconfined) { + kotlinx.coroutines.delay(amountMs) + } + } + + suspend fun Flow.expectAsync(matcher: (T) -> Boolean) { + val flow = this + + inProgressExpects.add(testScope.async(Dispatchers.Unconfined) { + flow.first { matcher(it) } + }) + } + + suspend fun waitForExpects() { + inProgressExpects.awaitAll() + } + + suspend fun Flow.expect(matcher: (T) -> Boolean) { + val flow = this + + val collected = mutableListOf() + val work = testScope.async { + flow.onEach { + collected.add(it) + }.first { matcher(it) } + } + withContext(Dispatchers.Unconfined) { + withTimeoutOrNull(5000) { work.await() } + }.ifNull { + fail("found no matches in $collected") + } + } + + suspend fun Flow.assert(expected: T) { + val flow = this + + val collected = mutableListOf() + val work = testScope.async { + flow.onEach { + collected.add(it) + }.first { it == expected } + } + withContext(Dispatchers.IO) { + withTimeoutOrNull(5000) { work.await() } + } + collected.lastOrNull() shouldBeEqualTo expected + } + + suspend fun TestMatrix.expectRoom(roomId: RoomId) { + this.client.syncService().overview() + .expect { it.any { it.roomId == roomId } } + } + + suspend fun TestMatrix.expectInvite(roomId: RoomId) { + this.client.syncService().invites() + .expect { it.any { it.roomId == roomId } } + } + + suspend fun TestMatrix.expectMessage(roomId: RoomId, message: TestMessage) { + this.client.syncService().room(roomId) + .map { it.events.filterIsInstance().map { TestMessage(it.content, it.author) }.firstOrNull() } + .assert(message) + } + + suspend fun TestMatrix.sendEncryptedMessage(roomId: RoomId, content: String) { + this.client.messageService().scheduleMessage( + MessageService.Message.TextMessage( + content = MessageService.Message.Content.TextContent(body = content), + roomId = roomId, + sendEncrypted = true, + ) + ) + } + + suspend fun TestMatrix.loginWithInitialSync() { + this.restoreLogin() + client.syncService().startSyncing().collectAsync(testScope) { + client.syncService().overview().first() + } + } + +} \ No newline at end of file diff --git a/test-harness/src/test/kotlin/test/TestExtensions.kt b/test-harness/src/test/kotlin/test/TestExtensions.kt new file mode 100644 index 0000000..0e2beaa --- /dev/null +++ b/test-harness/src/test/kotlin/test/TestExtensions.kt @@ -0,0 +1,14 @@ +package test + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.take + +fun Any.print() = this.also { println(this) } + +suspend fun Flow.collectItem(count: Int): T { + return this.take(count).last() +} + +fun T.unit() = Unit + diff --git a/test-harness/src/test/kotlin/test/TestMatrix.kt b/test-harness/src/test/kotlin/test/TestMatrix.kt new file mode 100644 index 0000000..91e08b5 --- /dev/null +++ b/test-harness/src/test/kotlin/test/TestMatrix.kt @@ -0,0 +1,273 @@ +package test + +import TestUser +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.SingletonFlows +import app.dapk.st.domain.StoreModule +import app.dapk.st.matrix.MatrixClient +import app.dapk.st.matrix.auth.AuthConfig +import app.dapk.st.matrix.auth.authService +import app.dapk.st.matrix.auth.installAuthService +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.crypto.RoomMembersProvider +import app.dapk.st.matrix.crypto.Verification +import app.dapk.st.matrix.crypto.cryptoService +import app.dapk.st.matrix.crypto.installCryptoService +import app.dapk.st.matrix.device.deviceService +import app.dapk.st.matrix.device.installEncryptionService +import app.dapk.st.matrix.device.internal.ApiMessage +import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory +import app.dapk.st.matrix.message.MessageEncrypter +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.message.installMessageService +import app.dapk.st.matrix.message.messageService +import app.dapk.st.matrix.push.installPushService +import app.dapk.st.matrix.room.RoomMessenger +import app.dapk.st.matrix.room.installRoomService +import app.dapk.st.matrix.room.roomService +import app.dapk.st.matrix.sync.* +import app.dapk.st.matrix.sync.internal.request.ApiToDeviceEvent +import app.dapk.st.matrix.sync.internal.room.MessageDecrypter +import app.dapk.st.olm.DeviceKeyFactory +import app.dapk.st.olm.OlmPersistenceWrapper +import app.dapk.st.olm.OlmWrapper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import test.impl.InMemoryDatabase +import test.impl.InMemoryPreferences +import test.impl.InstantScheduler +import test.impl.PrintingErrorTracking +import java.time.Clock + +class TestMatrix( + private val user: TestUser, + includeHttpLogging: Boolean = false, + includeLogging: Boolean = false, +) { + + private val errorTracker = PrintingErrorTracking(prefix = user.roomMember.id.value.split(":")[0]) + private val logger: MatrixLogger = { tag, message -> + if (includeLogging) { + println("${user.roomMember.id.value.split(":")[0]} $tag $message") + } + } + + private val preferences = InMemoryPreferences() + private val database = InMemoryDatabase.realInstance(user.roomMember.id.value) + private val coroutineDispatchers = CoroutineDispatchers(Dispatchers.Unconfined, CoroutineScope(Dispatchers.Unconfined)) + + private val storeModule = StoreModule( + database = database, + preferences = preferences, + errorTracker = errorTracker, + credentialPreferences = preferences, + databaseDropper = { + // do nothing + }, + coroutineDispatchers = coroutineDispatchers + ) + + val client = MatrixClient( + KtorMatrixHttpClientFactory( + storeModule.credentialsStore(), + includeLogging = includeHttpLogging, + ), + logger + ).also { + it.install { + installAuthService(storeModule.credentialsStore(), AuthConfig(forceHttp = false)) + installEncryptionService(storeModule.knownDevicesStore()) + + val olmAccountStore = OlmPersistenceWrapper(storeModule.olmStore()) + val olm = OlmWrapper( + olmStore = olmAccountStore, + singletonFlows = SingletonFlows(), + jsonCanonicalizer = JsonCanonicalizer(), + deviceKeyFactory = DeviceKeyFactory(JsonCanonicalizer()), + errorTracker = errorTracker, + logger = logger, + clock = Clock.systemUTC(), + coroutineDispatchers = coroutineDispatchers, + ) + installCryptoService( + storeModule.credentialsStore(), + olm, + roomMembersProvider = { services -> + RoomMembersProvider { + services.roomService().joinedMembers(it).map { it.userId } + } + }, + coroutineDispatchers = coroutineDispatchers, + ) + + installMessageService(storeModule.localEchoStore, InstantScheduler(it)) { serviceProvider -> + MessageEncrypter { message -> + val result = serviceProvider.cryptoService().encrypt( + roomId = when (message) { + is MessageService.Message.TextMessage -> message.roomId + }, + credentials = storeModule.credentialsStore().credentials()!!, + when (message) { + is MessageService.Message.TextMessage -> JsonString( + MatrixHttpClient.jsonWithDefaults.encodeToString( + ApiMessage.TextMessage( + ApiMessage.TextMessage.TextContent( + message.content.body, + message.content.type, + ), message.roomId, type = EventType.ROOM_MESSAGE.value + ) + ) + ) + } + ) + + MessageEncrypter.EncryptedMessagePayload( + result.algorithmName, + result.senderKey, + result.cipherText, + result.sessionId, + result.deviceId, + ) + } + } + + installRoomService( + storeModule.memberStore(), + roomMessenger = { + val messageService = it.messageService() + object : RoomMessenger { + override suspend fun enableEncryption(roomId: RoomId) { + messageService.sendEventMessage( + roomId, MessageService.EventMessage.Encryption( + algorithm = AlgorithmName("m.megolm.v1.aes-sha2") + ) + ) + } + } + } + ) + + installSyncService( + storeModule.credentialsStore(), + storeModule.overviewStore(), + storeModule.roomStore(), + storeModule.syncStore(), + storeModule.filterStore(), + messageDecrypter = { serviceProvider -> + MessageDecrypter { + serviceProvider.cryptoService().decrypt(it) + } + }, + keySharer = { serviceProvider -> + KeySharer { sharedRoomKeys -> + serviceProvider.cryptoService().importRoomKeys(sharedRoomKeys) + } + }, + verificationHandler = { services -> + val cryptoService = services.cryptoService() + VerificationHandler { apiEvent -> + logger.matrixLog(MatrixLogTag.VERIFICATION, "got a verification request $apiEvent") + cryptoService.onVerificationEvent( + when (apiEvent) { + is ApiToDeviceEvent.VerificationRequest -> Verification.Event.Requested( + apiEvent.sender, + apiEvent.content.fromDevice, + apiEvent.content.transactionId, + apiEvent.content.methods, + apiEvent.content.timestampPosix, + ) + is ApiToDeviceEvent.VerificationReady -> Verification.Event.Ready( + apiEvent.sender, + apiEvent.content.fromDevice, + apiEvent.content.transactionId, + apiEvent.content.methods, + ) + is ApiToDeviceEvent.VerificationStart -> Verification.Event.Started( + apiEvent.sender, + apiEvent.content.fromDevice, + apiEvent.content.method, + apiEvent.content.protocols, + apiEvent.content.hashes, + apiEvent.content.codes, + apiEvent.content.short, + apiEvent.content.transactionId, + ) + is ApiToDeviceEvent.VerificationAccept -> Verification.Event.Accepted( + apiEvent.sender, + apiEvent.content.fromDevice, + apiEvent.content.method, + apiEvent.content.protocol, + apiEvent.content.hash, + apiEvent.content.code, + apiEvent.content.short, + apiEvent.content.transactionId, + ) + is ApiToDeviceEvent.VerificationCancel -> TODO() + is ApiToDeviceEvent.VerificationKey -> Verification.Event.Key( + apiEvent.sender, + apiEvent.content.transactionId, + apiEvent.content.key + ) + is ApiToDeviceEvent.VerificationMac -> Verification.Event.Mac( + apiEvent.sender, + apiEvent.content.transactionId, + apiEvent.content.keys, + apiEvent.content.mac, + ) + } + ) + } + }, + deviceNotifier = { services -> + val encryptionService = services.deviceService() + val cryptoService = services.cryptoService() + DeviceNotifier { userIds, syncToken -> + encryptionService.updateStaleDevices(userIds) + cryptoService.updateOlmSession(userIds, syncToken) + } + }, + oneTimeKeyProducer = { services -> + val cryptoService = services.cryptoService() + MaybeCreateMoreKeys { + cryptoService.maybeCreateMoreKeys(it) + } + }, + roomMembersService = { services -> + val roomService = services.roomService() + object : RoomMembersService { + override suspend fun find(roomId: RoomId, userIds: List) = roomService.findMembers(roomId, userIds) + override suspend fun insert(roomId: RoomId, members: List) = roomService.insertMembers(roomId, members) + } + }, + errorTracker = errorTracker, + coroutineDispatchers = coroutineDispatchers, + syncConfig = SyncConfig(loopTimeout = 500, allowSharedFlows = false) + ) + installPushService(storeModule.credentialsStore()) + } + } + + suspend fun newlogin() { + client.authService() + .login(user.roomMember.id.value, user.password) + } + + suspend fun restoreLogin() { + val userId = this@TestMatrix.user.roomMember.id + val json = TestPersistence(prefix = "").readJson("credentials-${userId.value}.json")!! + val credentials = Json.decodeFromString(UserCredentials.serializer(), json) + storeModule.credentialsStore().update(credentials) + logger.matrixLog("restored: ${credentials.userId} : ${credentials.deviceId}") + } + + suspend fun saveLogin(result: UserCredentials) { + val userId = result.userId + TestPersistence(prefix = "").put("credentials-${userId.value}.json", UserCredentials.serializer(), result) + } + + suspend fun deviceId() = storeModule.credentialsStore().credentials()!!.deviceId + suspend fun userId() = storeModule.credentialsStore().credentials()!!.userId +} diff --git a/test-harness/src/test/kotlin/test/TestPersistence.kt b/test-harness/src/test/kotlin/test/TestPersistence.kt new file mode 100644 index 0000000..c28a578 --- /dev/null +++ b/test-harness/src/test/kotlin/test/TestPersistence.kt @@ -0,0 +1,72 @@ +package test + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import java.io.ByteArrayInputStream +import java.io.File +import java.io.ObjectInputStream +import java.io.Serializable +import java.util.* + +class TestPersistence( + prefix: String, +) { + + private val dir = File("build/st-test-persistence/${prefix}") + + init { + dir.mkdirs() + } + + suspend fun put(fileName: String, serializer: KSerializer, value: T) { + writeFile(fileName, Json.encodeToString(serializer, value)) + } + + suspend fun readOrPut(fileName: String, serializer: KSerializer, storeAction: suspend () -> T): T { + val file = File(dir, fileName) + + return if (file.exists()) { + Json.decodeFromString(serializer, file.readText()).also { + println("restored $it from $fileName") + } + } else { + storeAction().also { result -> + writeFile(fileName, Json.encodeToString(serializer, result)) + } + } + } + + suspend fun read(fileName: String): T? { + val file = File(dir, fileName) + + return if (file.exists()) { + val text = file.readBytes() + val decoded = Base64.getDecoder().decode(text) + ObjectInputStream(ByteArrayInputStream(decoded)).use { + it.readObject() as T + }.also { + println("restored $it from $fileName") + } + } else { + null + } + } + + suspend fun readJson(fileName: String): String? { + val file = File(dir, fileName) + + return if (file.exists()) { + file.readText() + } else { + null + } + } + + private fun writeFile(name: String, jsonContent: String) { + ensureFile(name).writeText(jsonContent) + } + + private fun ensureFile(fileName: String) = File(dir, fileName).also { + it.parentFile?.mkdirs() + } +} \ No newline at end of file diff --git a/test-harness/src/test/kotlin/test/impl/InMemoryDatabase.kt b/test-harness/src/test/kotlin/test/impl/InMemoryDatabase.kt new file mode 100644 index 0000000..a249e3d --- /dev/null +++ b/test-harness/src/test/kotlin/test/impl/InMemoryDatabase.kt @@ -0,0 +1,22 @@ +package test.impl + +import app.dapk.db.DapkDb +import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver +import java.io.File + +object InMemoryDatabase { + + fun realInstance(id: String): DapkDb { + val dbDir = "build/smalltalk-test-persistence" + val dbPath = "$dbDir/test-$id.db" + return DapkDb(JdbcSqliteDriver( + url = "jdbc:sqlite:${File(dbPath).absolutePath}", + ).also { + if (!File(dbPath).exists()) { + File(dbDir).mkdirs() + DapkDb.Schema.create(it) + } + }) + } + +} \ No newline at end of file diff --git a/test-harness/src/test/kotlin/test/impl/InMemoryPreferences.kt b/test-harness/src/test/kotlin/test/impl/InMemoryPreferences.kt new file mode 100644 index 0000000..f24a0b4 --- /dev/null +++ b/test-harness/src/test/kotlin/test/impl/InMemoryPreferences.kt @@ -0,0 +1,18 @@ +package test.impl + +import app.dapk.st.domain.Preferences +import test.unit + +class InMemoryPreferences : Preferences { + + private val prefs = mutableMapOf() + + override suspend fun store(key: String, value: String) { + prefs[key] = value + } + + override suspend fun readString(key: String): String? = prefs[key] + override suspend fun remove(key: String) = prefs.remove(key).unit() + override suspend fun clear() = prefs.clear() + +} \ No newline at end of file diff --git a/test-harness/src/test/kotlin/test/impl/InstantScheduler.kt b/test-harness/src/test/kotlin/test/impl/InstantScheduler.kt new file mode 100644 index 0000000..f0d7788 --- /dev/null +++ b/test-harness/src/test/kotlin/test/impl/InstantScheduler.kt @@ -0,0 +1,21 @@ +package test.impl + +import app.dapk.st.matrix.MatrixClient +import app.dapk.st.matrix.MatrixTaskRunner +import app.dapk.st.matrix.message.BackgroundScheduler +import kotlinx.coroutines.runBlocking + +class InstantScheduler(private val matrixClient: MatrixClient) : BackgroundScheduler { + + override fun schedule(key: String, task: BackgroundScheduler.Task) { + runBlocking { + matrixClient.run( + MatrixTaskRunner.MatrixTask( + task.type, + task.jsonPayload + ) + ) + } + } + +} \ No newline at end of file diff --git a/test-harness/src/test/kotlin/test/impl/PrintingErrorTracking.kt b/test-harness/src/test/kotlin/test/impl/PrintingErrorTracking.kt new file mode 100644 index 0000000..4f69ca7 --- /dev/null +++ b/test-harness/src/test/kotlin/test/impl/PrintingErrorTracking.kt @@ -0,0 +1,10 @@ +package test.impl + +import app.dapk.st.core.extensions.ErrorTracker + +class PrintingErrorTracking(private val prefix: String) : ErrorTracker { + override fun track(throwable: Throwable, extra: String) { + println("$prefix ${throwable.message}") + throwable.printStackTrace() + } +} \ No newline at end of file diff --git a/test-harness/src/test/resources/element-keys.txt b/test-harness/src/test/resources/element-keys.txt new file mode 100644 index 0000000..705470f --- /dev/null +++ b/test-harness/src/test/resources/element-keys.txt @@ -0,0 +1,176 @@ +-----BEGIN MEGOLM SESSION DATA----- +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 +-----END MEGOLM SESSION DATA----- diff --git a/tools/benchmark/benchmark.profile b/tools/benchmark/benchmark.profile new file mode 100644 index 0000000..e3ec5a8 --- /dev/null +++ b/tools/benchmark/benchmark.profile @@ -0,0 +1,8 @@ +clean_assemble { + tasks = ["clean", ":app:assembleDebug"] +} + +clean_assemble_no_cache { + tasks = ["clean", ":app:assembleDebug"] + gradle-args = ["--no-build-cache", "--no-configuration-cache"] +} diff --git a/tools/benchmark/run_benchmark.sh b/tools/benchmark/run_benchmark.sh new file mode 100755 index 0000000..1a313e4 --- /dev/null +++ b/tools/benchmark/run_benchmark.sh @@ -0,0 +1,15 @@ +if ! command -v gradle-profiler &> /dev/null +then + echo "gradle-profiler could not be found https://github.com/gradle/gradle-profiler" + exit +fi + +gradle-profiler \ + --benchmark \ + --project-dir . \ + --scenario-file tools/benchmark/benchmark.profile \ + --output-dir benchmark-out/output \ + --gradle-user-home benchmark-out/gradle-home \ + --warmups 3 \ + --iterations 3 \ + $1 \ No newline at end of file diff --git a/tools/check-size.sh b/tools/check-size.sh new file mode 100755 index 0000000..094bf32 --- /dev/null +++ b/tools/check-size.sh @@ -0,0 +1,13 @@ +#! /bin/bash + +rm -f build/bundle-tmp/app.apks + +./gradlew bundleRelease --no-configuration-cache -x uploadCrashlyticsMappingFileRelease + +bundletool build-apks \ +--device-spec=tools/device-spec.json \ +--bundle=app/build/outputs/bundle/release/app-release.aab --output=build/bundle-tmp/app.apks + +bundletool get-size total \ +--apks=build/bundle-tmp/app.apks \ +--human-readable-sizes \ No newline at end of file diff --git a/tools/coverage.gradle b/tools/coverage.gradle new file mode 100644 index 0000000..ec6ca27 --- /dev/null +++ b/tools/coverage.gradle @@ -0,0 +1,93 @@ +def excludes = [ + // DI graph + '**/*Module.*', + '**/*Module*.*', + + // Android composables + '**/*Screen*', + '**/components/*', + '**/*Compose*.*', + + // Android framework + '**/*Activity*', + '**/*AndroidService*', + '**/*Application*', + + // Generated + '**/*serializer*', + '**/*Serializer*', + "**/*request/*Companion*.*", + '**/*QueriesImpl*', + '**/*Db*', + '**/Select*', + + // Tmp until serializationx can ignore generated + '**/Api*', +] + +def initializeReport(report, projects, classExcludes) { + report.executionData fileTree(project.rootDir.absolutePath).include("**/build/jacoco/*.exec") + + def includeAndroid = { project, type -> + report.sourceDirectories.setFrom(report.sourceDirectories + files(["${project.projectDir}/src/main/kotlin"])) + def androidClasses = project.files([project.fileTree(dir: "${project.buildDir}/tmp/kotlin-classes/${type}", excludes: classExcludes)]) + report.classDirectories.setFrom(androidClasses + report.classDirectories) + } + + projects.each { project -> + project.apply plugin: 'jacoco' + project.afterEvaluate { + switch (project) { + case { it.plugins.hasPlugin("com.android.application") }: + includeAndroid(it, "debug") + break + case { it.plugins.hasPlugin("com.android.library") }: + includeAndroid(it, "release") + break + default: + report.sourceSets it.sourceSets.main + report.classDirectories.setFrom(files(report.classDirectories.files.collect { + fileTree(dir: it, excludes: classExcludes) + })) + } + } + } + + report.reports { + xml.enabled true + html.enabled true + csv.enabled false + } +} + +def collectProjects(predicate) { + return subprojects.findAll { it.buildFile.isFile() && predicate(it) } +} + +task unitCodeCoverageReport(type: JacocoReport) { + rootProject.apply plugin: 'jacoco' + def excludedProjects = [ + 'olm-stub', + 'test-harness' + ] + def projects = collectProjects { !excludedProjects.contains(it.name) } + dependsOn ":app:assembleDebug" + dependsOn { projects*.test } + initializeReport(it, projects, excludes) +} + +task harnessCodeCoverageReport(type: JacocoReport) { + rootProject.apply plugin: 'jacoco' + def projects = collectProjects { true } + dependsOn ":app:assembleDebug" + dependsOn { project(":test-harness").test } + initializeReport(it, projects, excludes) +} + +task allCodeCoverageReport(type: JacocoReport) { + rootProject.apply plugin: 'jacoco' + def projects = collectProjects { true } + dependsOn ":app:assembleDebug" + dependsOn { projects*.test } + initializeReport(it, projects, excludes) +} diff --git a/tools/debug.keystore b/tools/debug.keystore new file mode 100644 index 0000000000000000000000000000000000000000..37c6431b733dce778d3114f7f21f75d496e364df GIT binary patch literal 2763 zcma)6c{mdeAK!_&!dB^rF_J{t+((RDbA}KiCD&Y`!v`Vy`Td^vulN1q`+UCN&++~9gC_8Ifq-B%fj@x(|FFeQ z|6b)FJ?^hB%`oNCJvUlfD7;`YGA;hI$y-&&(qXHUh-Yhd-Z(lkr%|i?Nn%?0eR=QI z+hcVF_D>rku6pqdR&bk8t5|6TfVFMmgqzrtff|O|$APlC=LKgAO}{9*Ekd9A&Nfs0 zZRQ&Ti!s&`Q+}3WBmtXmerksBURGo%eX$LAm8Z}|{}ww-Hwd>@wj}oKOd{3y6J+J< z_|C7K%#BT($>egUgAfYhTUDC9%tZsUSqeT=c+$(K&mxPU+z!?bFM80nrNyPhXM>#^^mLrMnM?Mml^X1jJUwt#wvv8GH{vUZzH=)E z5_tl#%{ZdDFC#{yQ=&I;WulJz`YVU*ew**J;{@_v$Y4cg>J}nLc5Zxx z+yGwasyR0W@M@jr_FVDH`mBFPwAGl&5CYHsDE@uyyc_J9VPD_Vyes){OD!*#q#(r_ zmF^vIKk01!mRt_uGh@p%i!U#Trj@PvRVb^dEAtdw;Xp^_+nZ>G;p1}neRq^MS(S$E z?@YiJMw_sB$C=X{7q?%fv$1i0zzq4&@sgUqh$Wqq62m}S(mScDP|7kI5)D6}{>OCg z;-f1jk56Eg4wT*2Zl}D2D(2=dGsEoc|@X%57^qcxVs7T#W3ubLC zO61DM{T}LY`bfG%Ge`lfG9Eopd7aE!L_nL9Y~u7x?@L<#G(W1BS$NZFFv-s34DR`g zPgLxV0TzZi)K}HMVk~gLYbDPg`#FAO>xDDzO!!T-j$nDK8*#Q4Uo(kWy}SG>KAGJS zg_xD^e1N3aG&g05`j`IIqs>i}jLpDFOaGcE+6}WFu-ALK^OO71);dKvQ0#?=J1NuV zP{BlzCnsvEG)+{^S}&mwW&fJ{WM%QA&;Zp|YNN5g@U)gElktdxWp6p0y+N;*u~%^L zQ4UobAxBQ~XcvwC`2?_3`mmF%dsPvxjbyzE2y^+Nv!th%Sd(9!>LyfEKNpa%OdZQ8*Ke#N*LhBFzCM|0FR=%J6UA? z9Gc7>UI=yxNz{EA5_d!NPZ{WkQ?a|^AbD?Sf+S(Rz)$$X3 zS{MiP@xSX5hQP=mk${VU5P&xz9B>(c-$QSJ55Nm>4d4d|+}onisDJGHF$jp5BR<$i z2BWF2p{1^>p`)&?u8t-^%KtX;@{|!E*?SZT1O)5>@gD{Fzlz(_(@oEd1o!Zk(JSIm zz80v)-3k4FihGc%&6Iaq^1KC{Gck%kKbk;*h>+K^E_|@!eaLe7?#%CYz&1*{kzqhc ziLU8je!3q~XlzjrYxneenBUb^4-E@fONo4N&8|Q;R}w&UwO(GxxWI%4e9pR%d$en* z>Bwv*`^+y{TOLpND?_=UIhg1!G(b?=F@Wey_@Rc^1K%5ha~Am{t8hfZ?h>i*O#j2d zOVmF)8)6a~=D&)F^hh}?ohrUn$k*hNFlHb#bc56JB+j6B`3?5fYI z1fY%zhHPlwU{q45oJErE}tEpczrb# zOIup7XMHG^tv@^58Y;dim^Mv~wRbm1Y9t?2f`u>zZp?2FKd*mdo^x=lus$ds zl;oQKTIp<V)TbK(m3I5TaJMfq)j7)DC3(p`@w|xjOvVSxC@ZJPCZX4w*|z7TF{L_V33aI% z%hL`d-t4CEwS>mYMJsLTEX?wc83x*Y*}EBOqXGJ;96w}&++BUd9PAKG3^!hLSo#(s zQtD6TRw>H4RIaZ6I6cQjAaQLT_J<~73T0xe!*2etf7EzEHaF&_JUzf{^>bN7k8*8uQv=!^8BfMm zI_pVbPnzlX2Dh;7)%HKa1IaC1D;j z65~d-#k1c{r8u(|PDxq%?3bPM#Qwt3*v8pscf{dN4sJjWF|zgtVilSj=qaNz@{ z2pqpbj&jxA`J;i`WHY+e?Ki