├── .github ├── readme │ ├── fdroid-badge.png │ ├── google-play-badge.png │ └── header.png └── workflows │ ├── assemble.yml │ ├── check_size.yml │ ├── comment_size.yml │ ├── release-candidate.yml │ ├── release-train.yml │ └── test.yml ├── .gitignore ├── .gitmodules ├── .idea ├── .gitignore ├── .name ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml └── inspectionProfiles │ └── Project_Default.xml ├── LICENSE ├── README.md ├── app ├── build.gradle ├── proguard │ ├── app.pro │ ├── clear.pro │ ├── olm.pro │ └── serializationx.pro └── src │ ├── debug │ ├── AndroidManifest.xml │ └── res │ │ └── values │ │ ├── com_crashlytics_build_id.xml │ │ └── values.xml │ ├── main │ ├── AndroidManifest.xml │ ├── kotlin │ │ └── app │ │ │ └── dapk │ │ │ └── st │ │ │ ├── SmallTalkApplication.kt │ │ │ ├── graph │ │ │ └── AppModule.kt │ │ │ └── impl │ │ │ ├── AndroidBase64.kt │ │ │ ├── AndroidImageContentReader.kt │ │ │ ├── AppTaskRunner.kt │ │ │ ├── BackgroundWorkAdapter.kt │ │ │ ├── DefaultDatabaseDropper.kt │ │ │ ├── SharedPreferencesDelegate.kt │ │ │ ├── SmallTalkDeviceNameGenerator.kt │ │ │ └── TaskRunnerAdapter.kt │ └── res │ │ ├── mipmap-anydpi-v26 │ │ └── ic_launcher.xml │ │ ├── mipmap-xxhdpi │ │ └── ic_launcher_foreground.png │ │ ├── raw │ │ └── keep.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ └── shortcuts.xml │ └── release │ └── res │ └── values │ └── values.xml ├── build.gradle ├── core ├── build.gradle └── src │ ├── main │ └── kotlin │ │ └── app │ │ └── dapk │ │ └── st │ │ └── core │ │ ├── AndroidUri.kt │ │ ├── BuildMeta.kt │ │ ├── CoroutineDispatchers.kt │ │ ├── DeviceMeta.kt │ │ ├── HeliumLogger.kt │ │ ├── JobBag.kt │ │ ├── LRUCache.kt │ │ ├── Lce.kt │ │ ├── MimeType.kt │ │ ├── ModuleProvider.kt │ │ ├── Preferences.kt │ │ ├── RichText.kt │ │ ├── SingletonFlows.kt │ │ └── extensions │ │ ├── ErrorTracker.kt │ │ ├── FlowExtensions.kt │ │ ├── GlobalExtensions.kt │ │ ├── LceExtensions.kt │ │ ├── ListExtensions.kt │ │ ├── MapExtensions.kt │ │ └── Scope.kt │ └── testFixtures │ └── kotlin │ ├── fake │ ├── FakeErrorTracker.kt │ ├── FakeInputStream.kt │ └── FakeJobBag.kt │ ├── fixture │ └── CoroutineDispatchersFixture.kt │ └── test │ ├── ExpectTestScope.kt │ ├── FlowTestObserver.kt │ ├── MockkExtensions.kt │ └── TestSharedFlow.kt ├── design-library ├── build.gradle └── src │ └── main │ └── kotlin │ └── app │ └── dapk │ └── st │ └── design │ └── components │ ├── AlignedContainer.kt │ ├── Bubble.kt │ ├── ComposeExtensions.kt │ ├── Empty.kt │ ├── Error.kt │ ├── Icon.kt │ ├── OverflowMenu.kt │ ├── Theme.kt │ ├── Toolbar.kt │ └── itemRow.kt ├── domains ├── android │ ├── compose-core │ │ ├── build.gradle │ │ └── src │ │ │ └── main │ │ │ ├── AndroidManifest.xml │ │ │ └── kotlin │ │ │ └── app │ │ │ └── dapk │ │ │ └── st │ │ │ └── core │ │ │ ├── ActivityExtensions.kt │ │ │ ├── ComposeExtensions.kt │ │ │ ├── CoreAndroidModule.kt │ │ │ ├── DapkActivity.kt │ │ │ ├── ThemeStore.kt │ │ │ └── components │ │ │ └── Components.kt │ ├── core │ │ ├── build.gradle │ │ └── src │ │ │ └── main │ │ │ └── kotlin │ │ │ └── app │ │ │ └── dapk │ │ │ └── st │ │ │ └── core │ │ │ ├── ContentResolverExtensions.kt │ │ │ ├── ContextExtensions.kt │ │ │ └── DeviceMetaExtensions.kt │ ├── imageloader │ │ ├── build.gradle │ │ └── src │ │ │ └── main │ │ │ ├── AndroidManifest.xml │ │ │ └── kotlin │ │ │ └── app │ │ │ └── dapk │ │ │ └── st │ │ │ └── imageloader │ │ │ ├── ImageLoader.kt │ │ │ └── ImageLoaderModule.kt │ ├── push │ │ ├── build.gradle │ │ └── src │ │ │ ├── main │ │ │ ├── AndroidManifest.xml │ │ │ └── kotlin │ │ │ │ └── app │ │ │ │ └── dapk │ │ │ │ └── st │ │ │ │ └── push │ │ │ │ ├── PushHandler.kt │ │ │ │ ├── PushModule.kt │ │ │ │ ├── PushTokenRegistrar.kt │ │ │ │ ├── PushTokenRegistrars.kt │ │ │ │ ├── messaging │ │ │ │ ├── MessagingPushTokenRegistrar.kt │ │ │ │ └── MessagingServiceAdapter.kt │ │ │ │ └── unifiedpush │ │ │ │ ├── UnifiedPush.kt │ │ │ │ ├── UnifiedPushMessageDelegate.kt │ │ │ │ ├── UnifiedPushMessageReceiver.kt │ │ │ │ └── UnifiedPushRegistrar.kt │ │ │ └── test │ │ │ └── kotlin │ │ │ └── app │ │ │ └── dapk │ │ │ └── st │ │ │ └── push │ │ │ ├── PushTokenRegistrarsTest.kt │ │ │ ├── messaging │ │ │ ├── MessagingPushTokenRegistrarTest.kt │ │ │ └── MessagingServiceAdapterTest.kt │ │ │ └── unifiedpush │ │ │ ├── FakePushHandler.kt │ │ │ ├── UnifiedPushMessageDelegateTest.kt │ │ │ └── UnifiedPushRegistrarTest.kt │ ├── stub │ │ ├── build.gradle │ │ └── src │ │ │ └── testFixtures │ │ │ └── kotlin │ │ │ └── fake │ │ │ ├── FakeContentResolver.kt │ │ │ ├── FakeContext.kt │ │ │ ├── FakeCursor.kt │ │ │ ├── FakeInboxStyle.kt │ │ │ ├── FakeMessagingStyle.kt │ │ │ ├── FakeNotification.kt │ │ │ ├── FakeNotificationBuilder.kt │ │ │ ├── FakeNotificationManager.kt │ │ │ ├── FakePersonBuilder.kt │ │ │ └── FakeUri.kt │ ├── tracking │ │ ├── build.gradle │ │ └── src │ │ │ └── main │ │ │ ├── AndroidManifest.xml │ │ │ └── kotlin │ │ │ └── app │ │ │ └── dapk │ │ │ └── st │ │ │ └── tracking │ │ │ ├── CrashTrackerLogger.kt │ │ │ └── TrackingModule.kt │ ├── viewmodel-stub │ │ ├── build.gradle │ │ └── src │ │ │ └── main │ │ │ └── kotlin │ │ │ └── androidx │ │ │ ├── compose │ │ │ └── runtime │ │ │ │ └── MutableState.kt │ │ │ └── lifecycle │ │ │ └── ViewModel.kt │ ├── viewmodel │ │ ├── build.gradle │ │ └── src │ │ │ ├── main │ │ │ └── kotlin │ │ │ │ └── app │ │ │ │ └── dapk │ │ │ │ └── st │ │ │ │ └── viewmodel │ │ │ │ └── DapkViewModel.kt │ │ │ └── testFixtures │ │ │ └── kotlin │ │ │ ├── TestMutableState.kt │ │ │ ├── ViewModelTest.kt │ │ │ └── ViewModelTestScopeImpl.kt │ └── work │ │ ├── build.gradle │ │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ └── kotlin │ │ └── app │ │ └── dapk │ │ └── st │ │ └── work │ │ ├── TaskRunner.kt │ │ ├── WorkAndroidService.kt │ │ ├── WorkModule.kt │ │ ├── WorkScheduler.kt │ │ └── WorkSchedulingJobScheduler.kt ├── firebase │ ├── crashlytics-noop │ │ ├── build.gradle │ │ └── src │ │ │ └── main │ │ │ └── kotlin │ │ │ └── app │ │ │ └── dapk │ │ │ └── st │ │ │ └── firebase │ │ │ └── crashlytics │ │ │ └── CrashlyticsModule.kt │ ├── crashlytics │ │ ├── build.gradle │ │ └── src │ │ │ └── main │ │ │ ├── AndroidManifest.xml │ │ │ └── kotlin │ │ │ └── app │ │ │ └── dapk │ │ │ └── st │ │ │ └── firebase │ │ │ └── crashlytics │ │ │ ├── CrashlyticsCrashTracker.kt │ │ │ └── CrashlyticsModule.kt │ ├── messaging-noop │ │ ├── build.gradle │ │ └── src │ │ │ └── main │ │ │ ├── AndroidManifest.xml │ │ │ └── kotlin │ │ │ └── app │ │ │ └── dapk │ │ │ └── st │ │ │ └── firebase │ │ │ └── messaging │ │ │ ├── Messaging.kt │ │ │ ├── MessagingModule.kt │ │ │ └── ServiceDelegate.kt │ └── messaging │ │ ├── build.gradle │ │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ └── kotlin │ │ └── app │ │ └── dapk │ │ └── st │ │ └── firebase │ │ └── messaging │ │ ├── FirebasePushServiceDelegate.kt │ │ ├── Messaging.kt │ │ ├── MessagingModule.kt │ │ └── ServiceDelegate.kt └── store │ ├── build.gradle │ └── src │ ├── main │ ├── kotlin │ │ └── app │ │ │ └── dapk │ │ │ └── st │ │ │ └── domain │ │ │ ├── ApplicationPreferences.kt │ │ │ ├── DatabaseDropper.kt │ │ │ ├── StoreCleaner.kt │ │ │ ├── StoreModule.kt │ │ │ ├── application │ │ │ ├── eventlog │ │ │ │ ├── EventLogPersistence.kt │ │ │ │ └── LoggingStore.kt │ │ │ └── message │ │ │ │ └── MessageOptionsStore.kt │ │ │ ├── preference │ │ │ ├── CachingPreferences.kt │ │ │ └── PropertyCache.kt │ │ │ └── push │ │ │ └── PushTokenRegistrarPreferences.kt │ └── sqldelight │ │ └── app │ │ └── dapk │ │ └── db │ │ ├── migration │ │ └── 1.sqm │ │ └── model │ │ └── EventLogger.sq │ └── testFixtures │ └── kotlin │ └── fake │ ├── FakeLoggingStore.kt │ ├── FakeMessageOptionsStore.kt │ └── FakeStoreCleaner.kt ├── features ├── directory │ ├── build.gradle │ └── src │ │ ├── main │ │ └── kotlin │ │ │ └── app │ │ │ └── dapk │ │ │ └── st │ │ │ └── directory │ │ │ ├── DirectoryListingScreen.kt │ │ │ ├── DirectoryModule.kt │ │ │ ├── ShortcutHandler.kt │ │ │ └── state │ │ │ ├── DirectoryAction.kt │ │ │ ├── DirectoryReducer.kt │ │ │ └── DirectoryState.kt │ │ └── test │ │ └── kotlin │ │ └── app │ │ └── dapk │ │ └── st │ │ └── directory │ │ └── DirectoryReducerTest.kt ├── home │ ├── build.gradle │ └── src │ │ ├── main │ │ ├── AndroidManifest.xml │ │ └── kotlin │ │ │ └── app │ │ │ └── dapk │ │ │ └── st │ │ │ └── home │ │ │ ├── BetaVersionUpgradeUseCase.kt │ │ │ ├── HomeModule.kt │ │ │ ├── HomeScreen.kt │ │ │ ├── MainActivity.kt │ │ │ └── state │ │ │ ├── HomeAction.kt │ │ │ ├── HomeReducer.kt │ │ │ └── HomeState.kt │ │ └── test │ │ └── kotlin │ │ └── app │ │ └── dapk │ │ └── st │ │ └── home │ │ ├── BetaVersionUpgradeUseCaseTest.kt │ │ └── state │ │ └── HomeReducerTest.kt ├── login │ ├── build.gradle │ └── src │ │ ├── main │ │ └── kotlin │ │ │ └── app │ │ │ └── dapk │ │ │ └── st │ │ │ └── login │ │ │ ├── LoginModule.kt │ │ │ ├── LoginScreen.kt │ │ │ └── state │ │ │ ├── LoginAction.kt │ │ │ ├── LoginReducer.kt │ │ │ ├── LoginState.kt │ │ │ └── LoginUseCase.kt │ │ └── test │ │ └── kotlin │ │ └── app │ │ └── dapk │ │ └── st │ │ └── login │ │ └── state │ │ ├── LoginReducerTest.kt │ │ ├── LoginUseCaseTest.kt │ │ └── fakes │ │ └── FakeLoginUseCase.kt ├── messenger │ ├── build.gradle │ └── src │ │ ├── main │ │ ├── AndroidManifest.xml │ │ └── kotlin │ │ │ └── app │ │ │ └── dapk │ │ │ └── st │ │ │ └── messenger │ │ │ ├── CopyToClipboard.kt │ │ │ ├── DecryptingFetcher.kt │ │ │ ├── MessengerActivity.kt │ │ │ ├── MessengerModule.kt │ │ │ ├── MessengerScreen.kt │ │ │ ├── gallery │ │ │ ├── FetchMediaFoldersUseCase.kt │ │ │ ├── FetchMediaUseCase.kt │ │ │ ├── ImageGalleryActivity.kt │ │ │ ├── ImageGalleryModule.kt │ │ │ ├── ImageGalleryScreen.kt │ │ │ ├── MediaStoreExtensions.kt │ │ │ ├── MediaUriAvoidance.kt │ │ │ └── state │ │ │ │ ├── ImageGalleryActions.kt │ │ │ │ ├── ImageGalleryReducer.kt │ │ │ │ └── ImageGalleryState.kt │ │ │ ├── roomsettings │ │ │ ├── RoomSettingsActivity.kt │ │ │ └── RoomSettingsScreen.kt │ │ │ └── state │ │ │ ├── MessengerAction.kt │ │ │ ├── MessengerReducer.kt │ │ │ └── MessengerState.kt │ │ └── test │ │ └── kotlin │ │ └── app │ │ └── dapk │ │ └── st │ │ └── messenger │ │ ├── MessengerReducerTest.kt │ │ └── gallery │ │ ├── FetchMediaFoldersUseCaseTest.kt │ │ ├── FetchMediaUseCaseTest.kt │ │ └── state │ │ └── ImageGalleryReducerTest.kt ├── navigator │ ├── build.gradle │ └── src │ │ └── main │ │ └── kotlin │ │ └── app │ │ └── dapk │ │ └── st │ │ └── navigator │ │ └── Navigator.kt ├── notifications │ ├── build.gradle │ └── src │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ │ └── app │ │ │ │ └── dapk │ │ │ │ └── st │ │ │ │ └── notifications │ │ │ │ ├── AndroidNotificationBuilder.kt │ │ │ │ ├── AndroidNotificationStyle.kt │ │ │ │ ├── AndroidNotificationStyleBuilder.kt │ │ │ │ ├── NotificationChannels.kt │ │ │ │ ├── NotificationExtensions.kt │ │ │ │ ├── NotificationFactory.kt │ │ │ │ ├── NotificationInviteRenderer.kt │ │ │ │ ├── NotificationMessageRenderer.kt │ │ │ │ ├── NotificationStateMapper.kt │ │ │ │ ├── NotificationStyleFactory.kt │ │ │ │ ├── NotificationsModule.kt │ │ │ │ ├── RenderNotificationsUseCase.kt │ │ │ │ └── RoomEventsToNotifiableMapper.kt │ │ └── res │ │ │ └── drawable │ │ │ └── ic_notification_small_icon.xml │ │ └── test │ │ └── kotlin │ │ ├── app │ │ └── dapk │ │ │ └── st │ │ │ └── notifications │ │ │ ├── AndroidNotificationBuilderTest.kt │ │ │ ├── AndroidNotificationStyleBuilderTest.kt │ │ │ ├── NotificationFactoryTest.kt │ │ │ ├── NotificationRendererTest.kt │ │ │ ├── NotificationStateMapperTest.kt │ │ │ ├── NotificationStyleFactoryTest.kt │ │ │ ├── RenderNotificationsUseCaseTest.kt │ │ │ └── RoomEventsToNotifiableMapperTest.kt │ │ ├── fake │ │ ├── FakeNotificationChannels.kt │ │ ├── FakeNotificationFactory.kt │ │ ├── FakeNotificationInviteRenderer.kt │ │ └── FakeNotificationMessageRenderer.kt │ │ └── fixture │ │ ├── NotificationDelegateFixtures.kt │ │ └── NotificationFixtures.kt ├── profile │ ├── build.gradle │ └── src │ │ ├── main │ │ └── kotlin │ │ │ └── app │ │ │ └── dapk │ │ │ └── st │ │ │ └── profile │ │ │ ├── ProfileModule.kt │ │ │ ├── ProfileScreen.kt │ │ │ └── state │ │ │ ├── ProfileAction.kt │ │ │ ├── ProfileReducer.kt │ │ │ ├── ProfileState.kt │ │ │ └── ProfileUseCase.kt │ │ └── test │ │ └── kotlin │ │ └── app │ │ └── dapk │ │ └── st │ │ └── profile │ │ └── state │ │ ├── ProfileReducerTest.kt │ │ └── ProfileUseCaseTest.kt ├── settings │ ├── build.gradle │ └── src │ │ ├── main │ │ ├── AndroidManifest.xml │ │ └── kotlin │ │ │ └── app │ │ │ └── dapk │ │ │ └── st │ │ │ └── settings │ │ │ ├── SettingsActivity.kt │ │ │ ├── SettingsItemFactory.kt │ │ │ ├── SettingsModule.kt │ │ │ ├── SettingsScreen.kt │ │ │ ├── SettingsState.kt │ │ │ ├── UriFilenameResolver.kt │ │ │ ├── eventlogger │ │ │ ├── EventLogActivity.kt │ │ │ ├── EventLogScreen.kt │ │ │ ├── EventLoggerState.kt │ │ │ └── EventLoggerViewModel.kt │ │ │ └── state │ │ │ ├── SettingsAction.kt │ │ │ └── SettingsReducer.kt │ │ └── test │ │ └── kotlin │ │ ├── app │ │ └── dapk │ │ │ └── st │ │ │ └── settings │ │ │ ├── FakeThemeStore.kt │ │ │ ├── SettingsItemFactoryTest.kt │ │ │ ├── SettingsReducerTest.kt │ │ │ └── UriFilenameResolverTest.kt │ │ ├── internalfake │ │ ├── FakeSettingsItemFactory.kt │ │ └── FakeUriFilenameResolver.kt │ │ └── internalfixture │ │ ├── PageFixture.kt │ │ └── SettingItemFixture.kt ├── share-entry │ ├── build.gradle │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ └── kotlin │ │ └── app │ │ └── dapk │ │ └── st │ │ └── share │ │ ├── FetchRoomsUseCase.kt │ │ ├── ShareEntryActivity.kt │ │ ├── ShareEntryModule.kt │ │ ├── ShareEntryScreen.kt │ │ ├── ShareEntryState.kt │ │ └── ShareEntryViewModel.kt └── verification │ ├── build.gradle │ └── src │ └── main │ ├── AndroidManifest.xml │ └── kotlin │ └── app │ └── dapk │ └── st │ └── verification │ ├── VerificationActivity.kt │ ├── VerificationModule.kt │ ├── VerificationScreen.kt │ ├── VerificationState.kt │ └── VerificationViewModel.kt ├── gradle.properties ├── gradle ├── libs.versions.toml ├── repositories.gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── renovate.json ├── settings.gradle ├── tools ├── benchmark │ ├── benchmark.profile │ └── run_benchmark.sh ├── beta-release │ ├── app.js │ ├── package-lock.json │ ├── package.json │ └── release.js ├── check-size.sh ├── coverage.gradle ├── debug.keystore ├── device-spec.json ├── generate-fdroid-release.sh └── generate-release.sh └── version.json /.github/readme/fdroid-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ouchadam/small-talk/5a391676d0d482596fe5d270798c2b361dba67e7/.github/readme/fdroid-badge.png -------------------------------------------------------------------------------- /.github/readme/google-play-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ouchadam/small-talk/5a391676d0d482596fe5d270798c2b361dba67e7/.github/readme/google-play-badge.png -------------------------------------------------------------------------------- /.github/readme/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ouchadam/small-talk/5a391676d0d482596fe5d270798c2b361dba67e7/.github/readme/header.png -------------------------------------------------------------------------------- /.github/workflows/assemble.yml: -------------------------------------------------------------------------------- 1 | name: Assemble 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | pull_request: 8 | 9 | jobs: 10 | assemble-debug: 11 | name: Assemble debug variant 12 | runs-on: ubuntu-latest 13 | 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.ref }} 16 | cancel-in-progress: true 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | with: 21 | submodules: 'recursive' 22 | 23 | - uses: actions/setup-java@v3 24 | with: 25 | distribution: 'adopt' 26 | java-version: '11' 27 | - uses: gradle/gradle-build-action@v2 28 | 29 | - name: Assemble debug variant 30 | run: | 31 | ./gradlew assembleDebug --no-daemon 32 | ./gradlew assembleDebug -Pfoss --no-daemon 33 | -------------------------------------------------------------------------------- /.github/workflows/check_size.yml: -------------------------------------------------------------------------------- 1 | name: Check Size 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | check-size: 8 | name: Check Size 9 | runs-on: ubuntu-latest 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | with: 18 | submodules: 'recursive' 19 | 20 | - uses: actions/setup-java@v3 21 | with: 22 | distribution: 'adopt' 23 | java-version: '11' 24 | - uses: gradle/gradle-build-action@v2 25 | 26 | - name: Fetch bundletool 27 | run: | 28 | curl -s -L https://github.com/google/bundletool/releases/download/1.9.0/bundletool-all-1.9.0.jar --create-dirs -o bin/bundletool.jar 29 | chmod +x bin/bundletool.jar 30 | echo "#!/bin/bash" >> bin/bundletool 31 | echo 'java -jar $(dirname "$0")/bundletool.jar "$@"' >> bin/bundletool 32 | chmod +x bin/bundletool 33 | echo "$(pwd)/bin" >> $GITHUB_PATH 34 | 35 | - name: Save Size 36 | env: 37 | PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} 38 | run: | 39 | mkdir -p ./apk_size 40 | echo $(./tools/check-size.sh | tail -1 | cut -d ',' -f2-) > ./apk_size/size.txt 41 | echo $PULL_REQUEST_NUMBER > ./apk_size/pr_number.txt 42 | - uses: actions/upload-artifact@v3 43 | with: 44 | name: apk-size 45 | path: | 46 | apk_size/size.txt 47 | apk_size/pr_number.txt 48 | retention-days: 5 49 | -------------------------------------------------------------------------------- /.github/workflows/comment_size.yml: -------------------------------------------------------------------------------- 1 | name: Comment APK Size 2 | 3 | on: 4 | workflow_run: 5 | workflows: [ "Check Size" ] 6 | types: 7 | - completed 8 | 9 | jobs: 10 | comment-size: 11 | name: Comment Size 12 | runs-on: ubuntu-latest 13 | if: > 14 | ${{ github.event.workflow_run.event == 'pull_request' && 15 | github.event.workflow_run.conclusion == 'success' }} 16 | 17 | steps: 18 | - uses: dawidd6/action-download-artifact@v2 19 | with: 20 | name: apk-size 21 | workflow: ${{ github.event.workflow_run.workflow_id }} 22 | 23 | - name: Check release size 24 | run: | 25 | ls -R 26 | echo "::set-output name=APK_SIZE::$(cat size.txt)" 27 | echo "::set-output name=PR_NUMBER::$(cat pr_number.txt)" 28 | id: size 29 | 30 | - name: Find Comment 31 | uses: peter-evans/find-comment@v2 32 | id: fc 33 | with: 34 | issue-number: ${{ steps.size.outputs.PR_NUMBER }} 35 | comment-author: 'github-actions[bot]' 36 | body-includes: APK Size 37 | - name: Publish size to PR 38 | uses: peter-evans/create-or-update-comment@v3 39 | with: 40 | comment-id: ${{ steps.fc.outputs.comment-id }} 41 | issue-number: ${{ steps.size.outputs.PR_NUMBER }} 42 | body: | 43 | APK Size: ${{ steps.size.outputs.APK_SIZE }} 44 | edit-mode: replace -------------------------------------------------------------------------------- /.github/workflows/release-train.yml: -------------------------------------------------------------------------------- 1 | name: Release Train 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '0 19 * * 1,4' 7 | 8 | jobs: 9 | check-develop-beta-changes: 10 | name: Check if develop is ahead of beta release 11 | runs-on: ubuntu-latest 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | submodules: 'recursive' 21 | 22 | - uses: actions/setup-node@v3 23 | with: 24 | node-version: 25 | 16 26 | - run: npm ci 27 | working-directory: ./tools/beta-release/ 28 | 29 | - uses: actions/github-script@v6 30 | with: 31 | github-token: ${{ secrets.MY_PAT }} 32 | script: | 33 | const { startReleaseProcess } = await import('${{ github.workspace }}/tools/beta-release/app.js') 34 | await startReleaseProcess({github, context, core}) 35 | 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | pull_request: 8 | 9 | jobs: 10 | unit-tests: 11 | name: Run all unit tests (with coverage) 12 | runs-on: ubuntu-latest 13 | 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.ref }} 16 | cancel-in-progress: true 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | with: 21 | submodules: 'recursive' 22 | - uses: actions/setup-java@v3 23 | with: 24 | distribution: 'adopt' 25 | java-version: '11' 26 | - uses: gradle/gradle-build-action@v2 27 | 28 | - name: Run all unit tests 29 | run: ./gradlew allCodeCoverageReport 30 | 31 | - uses: codecov/codecov-action@v3 32 | with: 33 | verbose: true 34 | files: ./build/reports/jacoco/allCodeCoverageReport/allCodeCoverageReport.xml -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | .idea/*.xml 7 | .DS_Store 8 | **/build 9 | /captures 10 | .externalNativeBuild 11 | .cxx 12 | local.properties 13 | /benchmark-out 14 | **/node_modules 15 | .secrets -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "screen-state"] 2 | path = screen-state 3 | url = git@github.com:ouchadam/screen-state.git 4 | [submodule "chat-engine"] 5 | path = chat-engine 6 | url = git@github.com:ouchadam/chat-engine.git 7 | [submodule "tools/conventions"] 8 | path = tools/conventions 9 | url = git@github.com:ouchadam/conventions.git 10 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | SmallTalk -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 33 | -------------------------------------------------------------------------------- /app/proguard/app.pro: -------------------------------------------------------------------------------- 1 | -assumenosideeffects class android.util.Log { 2 | v(...); 3 | d(...); 4 | i(...); 5 | w(...); 6 | e(...); 7 | println(...); 8 | } -------------------------------------------------------------------------------- /app/proguard/clear.pro: -------------------------------------------------------------------------------- 1 | -keepnames class ** { *; } -------------------------------------------------------------------------------- /app/proguard/olm.pro: -------------------------------------------------------------------------------- 1 | -keepclassmembers class org.matrix.olm.** { *; } -------------------------------------------------------------------------------- /app/proguard/serializationx.pro: -------------------------------------------------------------------------------- 1 | -if @kotlinx.serialization.Serializable class ** 2 | -keepclassmembers class <1> { 3 | static <1>$Companion Companion; 4 | } 5 | 6 | -if @kotlinx.serialization.Serializable class ** { 7 | static **$* *; 8 | } 9 | -keepclassmembers class <1>$<3> { 10 | kotlinx.serialization.KSerializer serializer(...); 11 | } 12 | 13 | -keepclassmembers class <1> { 14 | public static <1> INSTANCE; 15 | kotlinx.serialization.KSerializer serializer(...); 16 | } 17 | -------------------------------------------------------------------------------- /app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/debug/res/values/com_crashlytics_build_id.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 00000000000000000000000000000000 4 | 5 | -------------------------------------------------------------------------------- /app/src/debug/res/values/values.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 390541134533-h9utldf4jb22qd5b6cs2cl8dkoohobjo.apps.googleusercontent.com 4 | 390541134533 5 | AIzaSyDS_TVmK-thJeXtLpobR1wwbhqJ1hISVik 6 | 1:390541134533:android:3f75d35c4dba1a287b3eac 7 | AIzaSyDS_TVmK-thJeXtLpobR1wwbhqJ1hISVik 8 | helium-6f01a.appspot.com 9 | helium-6f01a 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 15 | 16 | 20 | 21 | 22 | 23 | 24 | 25 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/dapk/st/impl/AndroidBase64.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.impl 2 | 3 | import app.dapk.engine.core.Base64 4 | 5 | internal class AndroidBase64 : Base64 { 6 | override fun encode(input: ByteArray): String { 7 | return android.util.Base64.encodeToString(input, android.util.Base64.DEFAULT) 8 | } 9 | 10 | override fun decode(input: String): ByteArray { 11 | return android.util.Base64.decode(input, android.util.Base64.DEFAULT) 12 | } 13 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/dapk/st/impl/AndroidImageContentReader.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.impl 2 | 3 | import android.content.ContentResolver 4 | import android.graphics.BitmapFactory 5 | import android.media.ExifInterface 6 | import android.net.Uri 7 | import android.provider.OpenableColumns 8 | import app.dapk.st.engine.ImageContentReader 9 | import java.io.InputStream 10 | 11 | internal class AndroidImageContentReader(private val contentResolver: ContentResolver) : ImageContentReader { 12 | override fun meta(uri: String): ImageContentReader.ImageContent { 13 | val androidUri = Uri.parse(uri) 14 | val fileStream = contentResolver.openInputStream(androidUri) ?: throw IllegalArgumentException("Could not process $uri") 15 | 16 | val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } 17 | BitmapFactory.decodeStream(fileStream, null, options) 18 | 19 | val fileSize = contentResolver.query(androidUri, null, null, null, null)?.use { cursor -> 20 | cursor.moveToFirst() 21 | val columnIndex = cursor.getColumnIndex(OpenableColumns.SIZE) 22 | cursor.getLong(columnIndex) 23 | } ?: throw IllegalArgumentException("Could not process $uri") 24 | 25 | val shouldSwapSizes = ExifInterface(contentResolver.openInputStream(androidUri) ?: throw IllegalArgumentException("Could not process $uri")).let { 26 | val orientation = it.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED) 27 | orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270 28 | } 29 | 30 | return ImageContentReader.ImageContent( 31 | height = if (shouldSwapSizes) options.outWidth else options.outHeight, 32 | width = if (shouldSwapSizes) options.outHeight else options.outWidth, 33 | size = fileSize, 34 | mimeType = options.outMimeType, 35 | fileName = androidUri.lastPathSegment ?: "file", 36 | ) 37 | } 38 | 39 | override fun inputStream(uri: String): InputStream = contentResolver.openInputStream(Uri.parse(uri))!! 40 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/dapk/st/impl/AppTaskRunner.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.impl 2 | 3 | import app.dapk.st.engine.ChatEngine 4 | import app.dapk.st.push.PushTokenPayload 5 | import app.dapk.st.work.TaskRunner 6 | import io.ktor.client.plugins.* 7 | import kotlinx.serialization.json.Json 8 | 9 | class AppTaskRunner( 10 | private val chatEngine: ChatEngine, 11 | ) { 12 | 13 | suspend fun run(workTask: TaskRunner.RunnableWorkTask): TaskRunner.TaskResult { 14 | return when (val type = workTask.task.type) { 15 | "push_token" -> { 16 | runCatching { 17 | val payload = Json.decodeFromString(PushTokenPayload.serializer(), workTask.task.jsonPayload) 18 | chatEngine.registerPushToken(payload.token, payload.gatewayUrl) 19 | }.fold( 20 | onSuccess = { TaskRunner.TaskResult.Success(workTask.source) }, 21 | onFailure = { 22 | val canRetry = if (it is ClientRequestException) { 23 | it.response.status.value !in (400 until 500) 24 | } else { 25 | true 26 | } 27 | TaskRunner.TaskResult.Failure(workTask.source, canRetry = canRetry) 28 | } 29 | ) 30 | } 31 | 32 | else -> throw IllegalArgumentException("Unknown work type: $type") 33 | } 34 | 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/dapk/st/impl/BackgroundWorkAdapter.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.impl 2 | 3 | import app.dapk.st.engine.BackgroundScheduler 4 | import app.dapk.st.work.WorkScheduler 5 | 6 | class BackgroundWorkAdapter(private val workScheduler: WorkScheduler) : BackgroundScheduler { 7 | override fun schedule(key: String, task: BackgroundScheduler.Task) { 8 | workScheduler.schedule( 9 | WorkScheduler.WorkTask( 10 | jobId = 1, 11 | type = task.type, 12 | jsonPayload = task.jsonPayload.value, 13 | ) 14 | ) 15 | } 16 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/dapk/st/impl/DefaultDatabaseDropper.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.impl 2 | 3 | import app.dapk.st.core.CoroutineDispatchers 4 | import app.dapk.st.core.withIoContext 5 | import app.dapk.st.domain.DatabaseDropper 6 | import com.squareup.sqldelight.android.AndroidSqliteDriver 7 | 8 | class DefaultDatabaseDropper( 9 | private val coroutineDispatchers: CoroutineDispatchers, 10 | private val driver: AndroidSqliteDriver, 11 | ) : DatabaseDropper { 12 | 13 | override suspend fun dropAllTables(deleteCrypto: Boolean) { 14 | coroutineDispatchers.withIoContext { 15 | val cursor = driver.executeQuery( 16 | identifier = null, 17 | sql = "SELECT name FROM sqlite_master WHERE type = 'table'", 18 | parameters = 0 19 | ) 20 | cursor.use { 21 | while (cursor.next()) { 22 | cursor.getString(0)?.let { 23 | if (!deleteCrypto && it.startsWith("dbCrypto")) { 24 | // skip 25 | } else { 26 | driver.execute(null, "DELETE FROM $it", 0) 27 | } 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/dapk/st/impl/SharedPreferencesDelegate.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.impl 2 | 3 | import android.content.Context 4 | import app.dapk.st.core.CoroutineDispatchers 5 | import app.dapk.st.core.Preferences 6 | import app.dapk.st.core.withIoContext 7 | 8 | internal class SharedPreferencesDelegate( 9 | context: Context, 10 | fileName: String, 11 | private val coroutineDispatchers: CoroutineDispatchers, 12 | ) : Preferences { 13 | 14 | private val preferences by lazy { context.getSharedPreferences(fileName, Context.MODE_PRIVATE) } 15 | 16 | override suspend fun store(key: String, value: String) { 17 | coroutineDispatchers.withIoContext { 18 | preferences.edit().putString(key, value).apply() 19 | } 20 | } 21 | 22 | override suspend fun readString(key: String): String? { 23 | return coroutineDispatchers.withIoContext { 24 | preferences.getString(key, null) 25 | } 26 | } 27 | 28 | override suspend fun remove(key: String) { 29 | coroutineDispatchers.withIoContext { 30 | preferences.edit().remove(key).apply() 31 | } 32 | } 33 | 34 | override suspend fun clear() { 35 | coroutineDispatchers.withIoContext { 36 | preferences.edit().clear().commit() 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/dapk/st/impl/SmallTalkDeviceNameGenerator.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.impl 2 | 3 | import app.dapk.st.engine.DeviceDisplayNameGenerator 4 | 5 | internal class SmallTalkDeviceNameGenerator : DeviceDisplayNameGenerator { 6 | override fun generate(): String { 7 | val randomIdentifier = (('A'..'Z') + ('a'..'z') + ('0'..'9')).shuffled().take(4).joinToString("") 8 | return "SmallTalk Android ($randomIdentifier)" 9 | } 10 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/dapk/st/impl/TaskRunnerAdapter.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.impl 2 | 3 | import app.dapk.st.engine.ChatEngine 4 | import app.dapk.st.engine.ChatEngineTask 5 | import app.dapk.st.work.TaskRunner 6 | 7 | class TaskRunnerAdapter( 8 | private val chatEngine: ChatEngine, 9 | private val appTaskRunner: AppTaskRunner, 10 | ) : TaskRunner { 11 | 12 | override suspend fun run(tasks: List): List { 13 | return tasks.map { 14 | when { 15 | it.task.type.startsWith("matrix") -> { 16 | when (val result = chatEngine.runTask(ChatEngineTask(it.task.type, it.task.jsonPayload))) { 17 | is app.dapk.st.engine.TaskRunner.TaskResult.Failure -> TaskRunner.TaskResult.Failure(it.source, canRetry = result.canRetry) 18 | app.dapk.st.engine.TaskRunner.TaskResult.Success -> TaskRunner.TaskResult.Success(it.source) 19 | } 20 | } 21 | 22 | else -> appTaskRunner.run(it) 23 | } 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ouchadam/small-talk/5a391676d0d482596fe5d270798c2b361dba67e7/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/raw/keep.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #D26CE6 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | SmallTalk 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/xml/shortcuts.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/release/res/values/values.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 390541134533-h9utldf4jb22qd5b6cs2cl8dkoohobjo.apps.googleusercontent.com 4 | 390541134533 5 | AIzaSyDS_TVmK-thJeXtLpobR1wwbhqJ1hISVik 6 | 1:390541134533:android:3f75d35c4dba1a287b3eac 7 | AIzaSyDS_TVmK-thJeXtLpobR1wwbhqJ1hISVik 8 | helium-6f01a.appspot.com 9 | helium-6f01a 10 | 11 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "st-base-conventions" apply false 3 | } 4 | 5 | def launchTask = getGradle() 6 | .getStartParameter() 7 | .getTaskRequests() 8 | .toString() 9 | .toLowerCase() 10 | ext.isReleaseBuild = launchTask.contains("bundlerelease") || launchTask.contains("assemblerelease") 11 | ext.isDebugBuild = !isReleaseBuild 12 | 13 | subprojects { 14 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { 15 | kotlinOptions { 16 | jvmTarget = "1.8" 17 | freeCompilerArgs = [ 18 | '-opt-in=kotlin.contracts.ExperimentalContracts', 19 | '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', 20 | ] 21 | } 22 | } 23 | } 24 | 25 | task clean(type: Delete) { 26 | delete rootProject.buildDir 27 | } 28 | 29 | ext.kotlinTest = { dependencies -> 30 | dependencies.testImplementation libs.kluent 31 | dependencies.testImplementation libs.kotlin.test 32 | dependencies.testImplementation libs.mockk 33 | dependencies.testImplementation libs.kotlin.coroutines.test 34 | } 35 | 36 | ext.kotlinFixtures = { dependencies -> 37 | dependencies.testFixturesImplementation libs.mockk 38 | dependencies.testFixturesImplementation libs.kluent 39 | dependencies.testFixturesImplementation libs.kotlin.coroutines 40 | } 41 | 42 | ext.androidImportFixturesWorkaround = { project, fixtures -> 43 | project.dependencies.testImplementation(project.dependencies.testFixtures(fixtures)) 44 | project.dependencies.testImplementation fixtures.files("build/libs/${fixtures.name}-test-fixtures.jar") 45 | project.dependencies.testImplementation fixtures.files("build/libs/${fixtures.name}.jar") 46 | } 47 | 48 | ext.isFoss = { 49 | return rootProject.hasProperty("foss") 50 | } 51 | 52 | ext.firebase = { dependencies, name -> 53 | if (isFoss()) { 54 | dependencies.implementation(project(":domains:firebase:$name-noop")) 55 | } else { 56 | dependencies.implementation(project(":domains:firebase:$name")) 57 | } 58 | } 59 | 60 | if (launchTask.contains("codeCoverageReport".toLowerCase())) { 61 | apply from: 'tools/coverage.gradle' 62 | } 63 | -------------------------------------------------------------------------------- /core/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'kotlin' 3 | id 'java-test-fixtures' 4 | } 5 | 6 | dependencies { 7 | api libs.kotlin.coroutines 8 | testFixturesImplementation libs.kotlin.coroutines 9 | testFixturesImplementation libs.kluent 10 | testFixturesImplementation libs.mockk 11 | testFixturesImplementation libs.kotlin.coroutines.test 12 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/app/dapk/st/core/AndroidUri.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core 2 | 3 | @JvmInline 4 | value class AndroidUri(val value: String) -------------------------------------------------------------------------------- /core/src/main/kotlin/app/dapk/st/core/BuildMeta.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core 2 | 3 | data class BuildMeta( 4 | val versionName: String, 5 | val versionCode: Int, 6 | val isDebug: Boolean, 7 | ) 8 | -------------------------------------------------------------------------------- /core/src/main/kotlin/app/dapk/st/core/CoroutineDispatchers.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core 2 | 3 | import kotlinx.coroutines.* 4 | 5 | data class CoroutineDispatchers( 6 | val io: CoroutineDispatcher = Dispatchers.IO, 7 | val main: CoroutineDispatcher = Dispatchers.Main, 8 | val global: CoroutineScope = GlobalScope, 9 | ) 10 | 11 | suspend fun CoroutineDispatchers.withIoContext( 12 | block: suspend CoroutineScope.() -> T 13 | ) = withContext(this.io, block) 14 | 15 | suspend fun CoroutineDispatchers.withIoContextAsync( 16 | block: suspend CoroutineScope.() -> T 17 | ): Deferred = withContext(this.io) { 18 | async { block() } 19 | } 20 | -------------------------------------------------------------------------------- /core/src/main/kotlin/app/dapk/st/core/DeviceMeta.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core 2 | 3 | data class DeviceMeta( 4 | val apiVersion: Int 5 | ) -------------------------------------------------------------------------------- /core/src/main/kotlin/app/dapk/st/core/HeliumLogger.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.onCompletion 5 | import kotlinx.coroutines.flow.onStart 6 | 7 | enum class AppLogTag(val key: String) { 8 | NOTIFICATION("notification"), 9 | PERFORMANCE("performance"), 10 | PUSH("push"), 11 | ERROR_NON_FATAL("error - non fatal"), 12 | } 13 | 14 | typealias AppLogger = (tag: String, message: String) -> Unit 15 | 16 | private var appLoggerInstance: AppLogger? = null 17 | 18 | fun attachAppLogger(logger: AppLogger) { 19 | appLoggerInstance = logger 20 | } 21 | 22 | fun log(tag: AppLogTag, message: Any) { 23 | appLoggerInstance?.invoke(tag.key, message.toString()) 24 | } 25 | 26 | suspend fun logP(area: String, block: suspend () -> T): T { 27 | val start = System.currentTimeMillis() 28 | return block().also { 29 | val timeTaken = System.currentTimeMillis() - start 30 | log(AppLogTag.PERFORMANCE, "$area: took $timeTaken ms") 31 | } 32 | } 33 | 34 | fun Flow.logP(area: String): Flow { 35 | var start = -1L 36 | return this 37 | .onStart { start = System.currentTimeMillis() } 38 | .onCompletion { 39 | val timeTaken = System.currentTimeMillis() - start 40 | log(AppLogTag.PERFORMANCE, "$area: took $timeTaken ms") 41 | } 42 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/app/dapk/st/core/JobBag.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core 2 | 3 | import kotlinx.coroutines.Job 4 | import kotlin.reflect.KClass 5 | 6 | class JobBag { 7 | 8 | private val jobs = mutableMapOf() 9 | 10 | fun replace(key: String, job: Job) { 11 | jobs[key]?.cancel() 12 | jobs[key] = job 13 | } 14 | 15 | fun replace(key: KClass<*>, job: Job) { 16 | jobs[key.java.canonicalName]?.cancel() 17 | jobs[key.java.canonicalName] = job 18 | } 19 | 20 | fun cancel(key: String) { 21 | jobs.remove(key)?.cancel() 22 | } 23 | 24 | fun cancel(key: KClass<*>) { 25 | jobs.remove(key.java.canonicalName)?.cancel() 26 | } 27 | 28 | fun cancelAll() { 29 | jobs.values.forEach { it.cancel() } 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/app/dapk/st/core/LRUCache.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core 2 | 3 | class LRUCache(val maxSize: Int) { 4 | 5 | private val internalCache = object : LinkedHashMap(0, 0.75f, true) { 6 | override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { 7 | return size > maxSize 8 | } 9 | } 10 | 11 | fun put(key: K, value: V) { 12 | internalCache[key] = value 13 | } 14 | 15 | fun get(key: K): V? { 16 | return internalCache[key] 17 | } 18 | 19 | fun getOrPut(key: K, value: () -> V): V { 20 | return get(key) ?: value().also { put(key, it) } 21 | } 22 | 23 | fun size() = internalCache.size 24 | 25 | } 26 | 27 | fun LRUCache<*, *>?.isNullOrEmpty() = this == null || this.size() == 0 -------------------------------------------------------------------------------- /core/src/main/kotlin/app/dapk/st/core/Lce.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core 2 | 3 | sealed interface Lce { 4 | data class Loading(val ignored: Unit = Unit) : Lce 5 | data class Error(val cause: Throwable) : Lce 6 | data class Content(val value: T) : Lce 7 | } 8 | 9 | sealed interface LceWithProgress { 10 | data class Loading(val progress: T) : LceWithProgress 11 | data class Error(val cause: Throwable) : LceWithProgress 12 | data class Content(val value: T) : LceWithProgress 13 | } 14 | 15 | -------------------------------------------------------------------------------- /core/src/main/kotlin/app/dapk/st/core/MimeType.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core 2 | 3 | sealed interface MimeType { 4 | object Image: MimeType 5 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/app/dapk/st/core/ModuleProvider.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core 2 | 3 | import kotlin.reflect.KClass 4 | 5 | interface ModuleProvider { 6 | 7 | fun provide(klass: KClass): T 8 | fun reset() 9 | } 10 | 11 | interface ProvidableModule -------------------------------------------------------------------------------- /core/src/main/kotlin/app/dapk/st/core/Preferences.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core 2 | 3 | interface Preferences { 4 | 5 | suspend fun store(key: String, value: String) 6 | suspend fun readString(key: String): String? 7 | suspend fun clear() 8 | suspend fun remove(key: String) 9 | } 10 | 11 | interface CachedPreferences : Preferences { 12 | suspend fun readString(key: String, defaultValue: String): String 13 | } 14 | 15 | suspend fun CachedPreferences.readBoolean(key: String, defaultValue: Boolean) = this 16 | .readString(key, defaultValue.toString()) 17 | .toBooleanStrict() 18 | 19 | suspend fun Preferences.readBoolean(key: String) = this.readString(key)?.toBooleanStrict() 20 | suspend fun Preferences.store(key: String, value: Boolean) = this.store(key, value.toString()) 21 | 22 | -------------------------------------------------------------------------------- /core/src/main/kotlin/app/dapk/st/core/RichText.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core 2 | 3 | data class RichText(val parts: List) { 4 | sealed interface Part { 5 | data class Normal(val content: String) : Part 6 | data class Link(val url: String, val label: String) : Part 7 | data class Bold(val content: String) : Part 8 | data class Italic(val content: String) : Part 9 | data class BoldItalic(val content: String) : Part 10 | data class Person(val displayName: String) : Part 11 | } 12 | } 13 | 14 | fun RichText.asString() = parts.joinToString(separator = "") { 15 | when(it) { 16 | is RichText.Part.Bold -> it.content 17 | is RichText.Part.BoldItalic -> it.content 18 | is RichText.Part.Italic -> it.content 19 | is RichText.Part.Link -> it.label 20 | is RichText.Part.Normal -> it.content 21 | is RichText.Part.Person -> it.displayName 22 | } 23 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/app/dapk/st/core/SingletonFlows.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core 2 | 3 | import kotlinx.coroutines.async 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.MutableSharedFlow 6 | import kotlinx.coroutines.sync.Mutex 7 | import kotlinx.coroutines.sync.withLock 8 | 9 | class SingletonFlows( 10 | private val coroutineDispatchers: CoroutineDispatchers 11 | ) { 12 | 13 | private val mutex = Mutex() 14 | private val cache = mutableMapOf>() 15 | 16 | @Suppress("unchecked_cast") 17 | suspend fun getOrPut(key: String, onStart: suspend () -> T): Flow { 18 | return when (val flow = cache[key]) { 19 | null -> mutex.withLock { 20 | cache.getOrPut(key) { 21 | MutableSharedFlow(replay = 1).also { 22 | coroutineDispatchers.withIoContext { 23 | async { 24 | it.emit(onStart()) 25 | } 26 | } 27 | } 28 | } as Flow 29 | } 30 | else -> flow as Flow 31 | } 32 | } 33 | 34 | @Suppress("UNCHECKED_CAST") 35 | fun get(key: String): Flow { 36 | return cache[key]!! as Flow 37 | } 38 | 39 | @Suppress("UNCHECKED_CAST") 40 | suspend fun update(key: String, value: T) { 41 | (cache[key] as? MutableSharedFlow)?.emit(value) 42 | } 43 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/app/dapk/st/core/extensions/ErrorTracker.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core.extensions 2 | 3 | interface ErrorTracker { 4 | fun track(throwable: Throwable, extra: String = "") 5 | } 6 | 7 | interface CrashScope { 8 | val errorTracker: ErrorTracker 9 | fun Result.trackFailure() = this.onFailure { errorTracker.track(it) } 10 | } 11 | 12 | fun ErrorTracker.nullAndTrack(throwable: Throwable, extra: String = ""): T? { 13 | this.track(throwable, extra) 14 | return null 15 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/app/dapk/st/core/extensions/FlowExtensions.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core.extensions 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.filter 5 | import kotlinx.coroutines.flow.takeWhile 6 | 7 | suspend fun Flow.firstOrNull(count: Int, predicate: suspend (T) -> Boolean): T? { 8 | var counter = 0 9 | 10 | var result: T? = null 11 | this 12 | .takeWhile { 13 | counter++ 14 | !predicate(it) || counter < (count + 1) 15 | } 16 | .filter { predicate(it) } 17 | .collect { 18 | result = it 19 | } 20 | 21 | return result 22 | } 23 | 24 | inline fun combine( 25 | flow: Flow, 26 | flow2: Flow, 27 | flow3: Flow, 28 | flow4: Flow, 29 | flow5: Flow, 30 | flow6: Flow, 31 | crossinline transform: suspend (T1, T2, T3, T4, T5, T6) -> R 32 | ): Flow { 33 | return kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> -> 34 | @Suppress("UNCHECKED_CAST") 35 | transform( 36 | args[0] as T1, 37 | args[1] as T2, 38 | args[2] as T3, 39 | args[3] as T4, 40 | args[4] as T5, 41 | args[5] as T6, 42 | ) 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /core/src/main/kotlin/app/dapk/st/core/extensions/GlobalExtensions.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core.extensions 2 | 3 | inline fun T?.ifNull(block: () -> T): T = this ?: block() 4 | inline fun ifOrNull(condition: Boolean, block: () -> T): T? = if (condition) block() else null 5 | 6 | inline fun Any.takeAs(): T? { 7 | return when (this) { 8 | is T -> this 9 | else -> null 10 | } 11 | } 12 | 13 | @Suppress("UNCHECKED_CAST") 14 | inline fun Iterable.firstOrNull(predicate: (T) -> Boolean, predicate2: (T) -> Boolean): Pair? { 15 | var firstValue: T1? = null 16 | var secondValue: T2? = null 17 | 18 | for (element in this) { 19 | if (firstValue == null && predicate(element)) { 20 | firstValue = element as T1 21 | } 22 | if (secondValue == null && predicate2(element)) { 23 | secondValue = element as T2 24 | } 25 | if (firstValue != null && secondValue != null) return firstValue to secondValue 26 | } 27 | return null 28 | } 29 | 30 | fun unsafeLazy(initializer: () -> T): Lazy = lazy(mode = LazyThreadSafetyMode.NONE, initializer = initializer) 31 | 32 | class ResettableUnsafeLazy(private val initializer: () -> T) : Lazy { 33 | 34 | private var _value: T? = null 35 | 36 | override val value: T 37 | get() { 38 | return if (_value == null) { 39 | initializer().also { _value = it } 40 | } else { 41 | _value!! 42 | } 43 | } 44 | 45 | override fun isInitialized(): Boolean { 46 | return _value != null 47 | } 48 | 49 | fun reset() { 50 | _value = null 51 | } 52 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/app/dapk/st/core/extensions/LceExtensions.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core.extensions 2 | 3 | import app.dapk.st.core.Lce 4 | 5 | fun Lce.takeIfContent(): T? { 6 | return when (this) { 7 | is Lce.Content -> this.value 8 | is Lce.Error -> null 9 | is Lce.Loading -> null 10 | } 11 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/app/dapk/st/core/extensions/ListExtensions.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core.extensions 2 | 3 | inline fun List.ifNotEmpty(transform: (List) -> List) = if (this.isEmpty()) emptyList() else transform(this) 4 | -------------------------------------------------------------------------------- /core/src/main/kotlin/app/dapk/st/core/extensions/MapExtensions.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core.extensions 2 | 3 | fun Map?.containsKey(key: K) = this?.containsKey(key) ?: false 4 | 5 | fun MutableMap.clearAndPutAll(input: Map) { 6 | this.clear() 7 | this.putAll(input) 8 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/app/dapk/st/core/extensions/Scope.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core.extensions 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.CoroutineStart 6 | import kotlinx.coroutines.Job 7 | import kotlinx.coroutines.SupervisorJob 8 | import kotlinx.coroutines.launch 9 | import kotlin.coroutines.CoroutineContext 10 | import kotlin.coroutines.EmptyCoroutineContext 11 | 12 | class Scope( 13 | dispatcher: CoroutineDispatcher 14 | ) { 15 | 16 | private val job = SupervisorJob() 17 | private val coroutineScope = CoroutineScope(dispatcher + job) 18 | 19 | fun launch( 20 | context: CoroutineContext = EmptyCoroutineContext, 21 | start: CoroutineStart = CoroutineStart.DEFAULT, 22 | block: suspend CoroutineScope.() -> Unit, 23 | ): Job { 24 | return coroutineScope.launch(context, start, block) 25 | } 26 | 27 | fun cancel() { 28 | job.cancel() 29 | } 30 | } -------------------------------------------------------------------------------- /core/src/testFixtures/kotlin/fake/FakeErrorTracker.kt: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import app.dapk.st.core.extensions.ErrorTracker 4 | import io.mockk.mockk 5 | 6 | class FakeErrorTracker : ErrorTracker by mockk(relaxed = true) -------------------------------------------------------------------------------- /core/src/testFixtures/kotlin/fake/FakeInputStream.kt: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import io.mockk.mockk 4 | import java.io.InputStream 5 | 6 | class FakeInputStream { 7 | val instance = mockk() 8 | } 9 | -------------------------------------------------------------------------------- /core/src/testFixtures/kotlin/fake/FakeJobBag.kt: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import app.dapk.st.core.JobBag 4 | import io.mockk.mockk 5 | 6 | class FakeJobBag { 7 | val instance = mockk() 8 | } 9 | 10 | -------------------------------------------------------------------------------- /core/src/testFixtures/kotlin/fixture/CoroutineDispatchersFixture.kt: -------------------------------------------------------------------------------- 1 | package fixture 2 | 3 | import app.dapk.st.core.CoroutineDispatchers 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.Dispatchers 6 | 7 | object CoroutineDispatchersFixture { 8 | 9 | fun aCoroutineDispatchers() = CoroutineDispatchers( 10 | Dispatchers.Unconfined, 11 | main = Dispatchers.Unconfined, 12 | global = CoroutineScope(Dispatchers.Unconfined) 13 | ) 14 | } -------------------------------------------------------------------------------- /core/src/testFixtures/kotlin/test/ExpectTestScope.kt: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import io.mockk.* 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.test.runTest 6 | import kotlin.coroutines.CoroutineContext 7 | 8 | fun runExpectTest(testBody: suspend ExpectTestScope.() -> Unit) { 9 | runTest { 10 | val expectTest = ExpectTest(coroutineContext) 11 | testBody(expectTest) 12 | } 13 | 14 | } 15 | 16 | class ExpectTest(override val coroutineContext: CoroutineContext) : ExpectTestScope { 17 | 18 | private val expects = mutableListOf Unit>>() 19 | private val groups = mutableListOf Unit>() 20 | 21 | override fun verifyExpects() { 22 | expects.forEach { (times, block) -> coVerify(exactly = times) { block.invoke(this) } } 23 | groups.forEach { coVerifyAll { it.invoke(this) } } 24 | } 25 | 26 | override fun T.expectUnit(times: Int, block: suspend MockKMatcherScope.(T) -> Unit) { 27 | coJustRun { block(this@expectUnit) }.ignore() 28 | expects.add(times to { block(this@expectUnit) }) 29 | } 30 | 31 | override fun T.expect(times: Int, block: suspend MockKMatcherScope.(T) -> Unit) { 32 | coJustRun { block(this@expect) } 33 | expects.add(times to { block(this@expect) }) 34 | } 35 | 36 | override fun T.captureExpects(block: suspend MockKMatcherScope.(T) -> Unit) { 37 | groups.add { block(this@captureExpects) } 38 | } 39 | } 40 | 41 | private fun Any.ignore() = Unit 42 | 43 | interface ExpectTestScope : CoroutineScope { 44 | fun verifyExpects() 45 | fun T.expectUnit(times: Int = 1, block: suspend MockKMatcherScope.(T) -> Unit) 46 | fun T.expect(times: Int = 1, block: suspend MockKMatcherScope.(T) -> Unit) 47 | fun T.captureExpects(block: suspend MockKMatcherScope.(T) -> Unit) 48 | } -------------------------------------------------------------------------------- /core/src/testFixtures/kotlin/test/FlowTestObserver.kt: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Job 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.launchIn 7 | import kotlinx.coroutines.flow.onEach 8 | import org.amshove.kluent.internal.assertEquals 9 | 10 | class FlowTestObserver(scope: CoroutineScope, flow: Flow) { 11 | 12 | private val values = mutableListOf() 13 | private val job: Job = flow 14 | .onEach { values.add(it) } 15 | .launchIn(scope) 16 | 17 | fun assertValues(values: List) = assertEquals(values, this.values) 18 | fun finish() = job.cancel() 19 | } -------------------------------------------------------------------------------- /core/src/testFixtures/kotlin/test/MockkExtensions.kt: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import io.mockk.* 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.flowOf 6 | 7 | inline fun T.expect(crossinline block: suspend MockKMatcherScope.(T) -> R) { 8 | coEvery { block(this@expect) } returns mockk(relaxed = true) 9 | } 10 | 11 | fun MockKStubScope.delegateReturn() = object : Returns { 12 | override fun returns(value: T) { 13 | answers(ConstantAnswer(value)) 14 | } 15 | 16 | override fun throws(value: Throwable) { 17 | this@delegateReturn.throws(value) 18 | } 19 | } 20 | 21 | fun MockKStubScope, B>.delegateEmit() = object : Emits { 22 | override fun emits(vararg values: T) { 23 | answers(ConstantAnswer(flowOf(*values))) 24 | } 25 | } 26 | 27 | 28 | fun returns(block: (T) -> Unit) = object : Returns { 29 | override fun returns(value: T) = block(value) 30 | override fun throws(value: Throwable) = throw value 31 | } 32 | 33 | interface Emits { 34 | fun emits(vararg values: T) 35 | } 36 | 37 | interface Returns { 38 | fun returns(value: T) 39 | fun throws(value: Throwable) 40 | } 41 | -------------------------------------------------------------------------------- /core/src/testFixtures/kotlin/test/TestSharedFlow.kt: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import kotlinx.coroutines.flow.MutableSharedFlow 4 | import org.amshove.kluent.shouldBeEqualTo 5 | 6 | class TestSharedFlow( 7 | private val instance: MutableSharedFlow = MutableSharedFlow() 8 | ) : MutableSharedFlow by instance { 9 | 10 | private val values = mutableListOf() 11 | 12 | override suspend fun emit(value: T) { 13 | values.add(value) 14 | instance.emit(value) 15 | } 16 | 17 | override fun tryEmit(value: T): Boolean { 18 | values.add(value) 19 | return instance.tryEmit(value) 20 | } 21 | 22 | fun assertNoValues() { 23 | values shouldBeEqualTo emptyList() 24 | } 25 | 26 | fun assertValues(vararg expected: T) { 27 | this.values shouldBeEqualTo expected.toList() 28 | } 29 | } -------------------------------------------------------------------------------- /design-library/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "st-android-compose-library-conventions" 3 | } 4 | 5 | android { 6 | namespace "app.dapk.st.design" 7 | } 8 | 9 | dependencies { 10 | implementation project(":core") 11 | implementation libs.compose.coil 12 | implementation libs.accompanist.systemuicontroller 13 | } 14 | -------------------------------------------------------------------------------- /design-library/src/main/kotlin/app/dapk/st/design/components/ComposeExtensions.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.design.components 2 | 3 | import android.content.res.Configuration 4 | import androidx.compose.ui.unit.Dp 5 | import androidx.compose.ui.unit.dp 6 | 7 | fun Configuration.percentOfHeight(float: Float): Dp { 8 | return (this.screenHeightDp * float).dp 9 | } -------------------------------------------------------------------------------- /design-library/src/main/kotlin/app/dapk/st/design/components/Empty.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.design.components 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | 11 | @Composable 12 | fun GenericEmpty() { 13 | Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 14 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 15 | Text("Nothing to see here...") 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /design-library/src/main/kotlin/app/dapk/st/design/components/Error.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.design.components 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material3.AlertDialog 6 | import androidx.compose.material3.Button 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.mutableStateOf 10 | import androidx.compose.runtime.remember 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.dp 14 | import androidx.compose.ui.unit.sp 15 | 16 | @Composable 17 | fun GenericError(message: String = "Something went wrong...", label: String = "Retry", cause: Throwable? = null, action: () -> Unit) { 18 | val moreDetails = cause?.let { "${it::class.java.simpleName}: ${it.message}" } 19 | 20 | val openDetailsDialog = remember { mutableStateOf(false) } 21 | if (openDetailsDialog.value) { 22 | AlertDialog( 23 | onDismissRequest = { openDetailsDialog.value = false }, 24 | confirmButton = { 25 | Button(onClick = { openDetailsDialog.value = false }) { 26 | Text("OK") 27 | } 28 | }, 29 | title = { Text("Details") }, 30 | text = { 31 | Text(moreDetails!!) 32 | } 33 | ) 34 | } 35 | Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { 36 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 37 | Text(message) 38 | if (moreDetails != null) { 39 | Text("Tap for more details".uppercase(), fontSize = 12.sp, modifier = Modifier.clickable { openDetailsDialog.value = true }.padding(12.dp)) 40 | } 41 | Spacer(modifier = Modifier.height(12.dp)) 42 | Button(onClick = { action() }) { 43 | Text(label.uppercase()) 44 | } 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /design-library/src/main/kotlin/app/dapk/st/design/components/OverflowMenu.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.design.components 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.outlined.MoreVert 7 | import androidx.compose.material3.DropdownMenu 8 | import androidx.compose.material3.Icon 9 | import androidx.compose.material3.IconButton 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.runtime.* 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.DpOffset 14 | import androidx.compose.ui.unit.dp 15 | 16 | @Composable 17 | fun OverflowMenu(content: @Composable () -> Unit) { 18 | var showMenu by remember { mutableStateOf(false) } 19 | 20 | Box { 21 | DropdownMenu( 22 | expanded = showMenu, 23 | onDismissRequest = { showMenu = false }, 24 | offset = DpOffset(0.dp, (-72).dp), 25 | modifier = Modifier.background(MaterialTheme.colorScheme.secondaryContainer) 26 | ) { 27 | content() 28 | } 29 | IconButton(onClick = { 30 | showMenu = !showMenu 31 | }) { 32 | Icon( 33 | imageVector = Icons.Outlined.MoreVert, 34 | contentDescription = null, 35 | ) 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /design-library/src/main/kotlin/app/dapk/st/design/components/Toolbar.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.design.components 2 | 3 | import androidx.compose.foundation.layout.RowScope 4 | import androidx.compose.foundation.layout.offset 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.filled.ArrowBack 7 | import androidx.compose.material3.* 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.unit.Density 12 | import androidx.compose.ui.unit.IntOffset 13 | 14 | @OptIn(ExperimentalMaterial3Api::class) 15 | @Composable 16 | fun Toolbar( 17 | onNavigate: (() -> Unit)? = null, 18 | title: String? = null, 19 | offset: (Density.() -> IntOffset)? = null, 20 | color: Color = MaterialTheme.colorScheme.background, 21 | actions: @Composable RowScope.() -> Unit = {} 22 | ) { 23 | val navigationIcon = foo(onNavigate) 24 | TopAppBar( 25 | modifier = offset?.let { Modifier.offset(it) } ?: Modifier, 26 | colors = TopAppBarDefaults.smallTopAppBarColors( 27 | containerColor = color, 28 | ), 29 | navigationIcon = navigationIcon, 30 | title = title?.let { { Text(it, maxLines = 2) } } ?: {}, 31 | actions = actions, 32 | ) 33 | } 34 | 35 | private fun foo(onNavigate: (() -> Unit)?): (@Composable () -> Unit) { 36 | return onNavigate?.let { 37 | { NavigationIcon(it) } 38 | } ?: {} 39 | } 40 | 41 | @Composable 42 | private fun NavigationIcon(onNavigate: () -> Unit) { 43 | IconButton(onClick = { onNavigate.invoke() }) { 44 | Icon(Icons.Default.ArrowBack, contentDescription = null) 45 | } 46 | } -------------------------------------------------------------------------------- /domains/android/compose-core/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "st-android-compose-library-conventions" 3 | } 4 | 5 | dependencies { 6 | implementation project(":core") 7 | implementation project(":features:navigator") 8 | implementation project(":design-library") 9 | api project(":domains:android:core") 10 | } 11 | -------------------------------------------------------------------------------- /domains/android/compose-core/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /domains/android/compose-core/src/main/kotlin/app/dapk/st/core/ActivityExtensions.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core 2 | 3 | import androidx.activity.ComponentActivity 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.ViewModelLazy 6 | import androidx.lifecycle.ViewModelProvider 7 | import androidx.lifecycle.ViewModelProvider.* 8 | import androidx.lifecycle.ViewModelStore 9 | import androidx.lifecycle.viewmodel.CreationExtras 10 | import kotlin.reflect.KClass 11 | 12 | inline fun ComponentActivity.viewModel( 13 | noinline factory: () -> VM 14 | ): Lazy { 15 | val factoryPromise = object : Factory { 16 | @Suppress("UNCHECKED_CAST") 17 | override fun create(modelClass: Class) = when (modelClass) { 18 | VM::class.java -> factory() as T 19 | else -> throw Error() 20 | } 21 | } 22 | return ViewModelLazy(VM::class, { viewModelStore }, { factoryPromise }) 23 | } 24 | -------------------------------------------------------------------------------- /domains/android/compose-core/src/main/kotlin/app/dapk/st/core/CoreAndroidModule.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core 2 | 3 | import app.dapk.st.navigator.IntentFactory 4 | 5 | class CoreAndroidModule( 6 | private val intentFactory: IntentFactory, 7 | private val preferences: Lazy, 8 | ) : ProvidableModule { 9 | 10 | fun intentFactory() = intentFactory 11 | 12 | fun themeStore() = ThemeStore(preferences.value) 13 | 14 | } -------------------------------------------------------------------------------- /domains/android/compose-core/src/main/kotlin/app/dapk/st/core/ThemeStore.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core 2 | 3 | private const val KEY_MATERIAL_YOU_ENABLED = "material_you_enabled" 4 | 5 | class ThemeStore( 6 | private val preferences: CachedPreferences 7 | ) { 8 | 9 | suspend fun isMaterialYouEnabled() = preferences.readBoolean(KEY_MATERIAL_YOU_ENABLED, defaultValue = false) 10 | 11 | suspend fun storeMaterialYouEnabled(isEnabled: Boolean) { 12 | preferences.store(KEY_MATERIAL_YOU_ENABLED, isEnabled) 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /domains/android/compose-core/src/main/kotlin/app/dapk/st/core/components/Components.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core.components 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.layout.wrapContentSize 7 | import androidx.compose.material3.CircularProgressIndicator 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.text.font.FontWeight 14 | import androidx.compose.ui.unit.dp 15 | import androidx.compose.ui.unit.sp 16 | 17 | @Composable 18 | fun Header(label: String) { 19 | Box(Modifier.padding(top = 24.dp, start = 24.dp, end = 24.dp)) { 20 | Text(text = label.uppercase(), fontWeight = FontWeight.Bold, fontSize = 12.sp, color = MaterialTheme.colorScheme.primary) 21 | } 22 | } 23 | 24 | @Composable 25 | fun CenteredLoading() { 26 | Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 27 | CircularProgressIndicator(Modifier.wrapContentSize()) 28 | } 29 | } -------------------------------------------------------------------------------- /domains/android/core/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "kotlin" 3 | } 4 | 5 | dependencies { 6 | compileOnly project(":domains:android:stub") 7 | compileOnly libs.androidx.annotation 8 | implementation project(":core") 9 | } 10 | -------------------------------------------------------------------------------- /domains/android/core/src/main/kotlin/app/dapk/st/core/ContentResolverExtensions.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core 2 | 3 | import android.content.ContentResolver 4 | import android.database.Cursor 5 | import android.net.Uri 6 | 7 | data class ContentResolverQuery( 8 | val uri: Uri, 9 | val projection: List, 10 | val selection: String, 11 | val selectionArgs: List, 12 | val sortBy: String, 13 | ) 14 | 15 | inline fun ContentResolver.reduce(query: ContentResolverQuery, operation: (Cursor) -> T): List { 16 | return this.reduce(query, mutableListOf()) { acc, cursor -> 17 | acc.add(operation(cursor)) 18 | acc 19 | } 20 | } 21 | 22 | inline fun ContentResolver.reduce(query: ContentResolverQuery, initial: T, operation: (T, Cursor) -> T): T { 23 | var accumulator: T = initial 24 | this.query(query.uri, query.projection.toTypedArray(), query.selection, query.selectionArgs.toTypedArray(), query.sortBy).use { cursor -> 25 | while (cursor != null && cursor.moveToNext()) { 26 | accumulator = operation(accumulator, cursor) 27 | } 28 | } 29 | return accumulator 30 | } 31 | -------------------------------------------------------------------------------- /domains/android/core/src/main/kotlin/app/dapk/st/core/ContextExtensions.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core 2 | 3 | import android.content.Context 4 | 5 | inline fun Context.module() = (this.applicationContext as ModuleProvider).provide(T::class) 6 | 7 | fun Context.resetModules() = (this.applicationContext as ModuleProvider).reset() -------------------------------------------------------------------------------- /domains/android/core/src/main/kotlin/app/dapk/st/core/DeviceMetaExtensions.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.core 2 | 3 | import android.os.Build 4 | import androidx.annotation.ChecksSdkIntAtLeast 5 | 6 | @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O, lambda = 0) 7 | fun DeviceMeta.isAtLeastO(block: () -> T, fallback: () -> T = { throw IllegalStateException("not handled") }): T { 8 | return if (this.apiVersion >= Build.VERSION_CODES.O) block() else fallback() 9 | } 10 | 11 | @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) 12 | fun DeviceMeta.isAtLeastS() = this.apiVersion >= Build.VERSION_CODES.S 13 | 14 | @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O, lambda = 0) 15 | fun DeviceMeta.onAtLeastO(block: () -> Unit) { 16 | whenXOrHigher(Build.VERSION_CODES.O, block, fallback = {}) 17 | } 18 | 19 | @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q, lambda = 0) 20 | fun DeviceMeta.onAtLeastQ(block: () -> Unit) { 21 | whenXOrHigher(Build.VERSION_CODES.Q, block, fallback = {}) 22 | } 23 | 24 | @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.R, lambda = 0) 25 | fun DeviceMeta.onAtLeastR(block: () -> Unit) { 26 | whenXOrHigher(Build.VERSION_CODES.R, block, fallback = {}) 27 | } 28 | 29 | inline fun DeviceMeta.whenPOrHigher(block: () -> T, fallback: () -> T) = whenXOrHigher(Build.VERSION_CODES.P, block, fallback) 30 | inline fun DeviceMeta.whenOOrHigher(block: () -> T, fallback: () -> T) = whenXOrHigher(Build.VERSION_CODES.O, block, fallback) 31 | 32 | @ChecksSdkIntAtLeast(parameter = 0, lambda = 1) 33 | inline fun DeviceMeta.whenXOrHigher(version: Int, block: () -> T, fallback: () -> T): T { 34 | return if (this.apiVersion >= version) block() else fallback() 35 | } 36 | 37 | -------------------------------------------------------------------------------- /domains/android/imageloader/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "st-android-library-conventions" 3 | } 4 | 5 | dependencies { 6 | implementation project(":core") 7 | implementation libs.compose.coil 8 | } 9 | -------------------------------------------------------------------------------- /domains/android/imageloader/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /domains/android/imageloader/src/main/kotlin/app/dapk/st/imageloader/ImageLoader.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.imageloader 2 | 3 | import android.content.Context 4 | import android.graphics.drawable.BitmapDrawable 5 | import android.graphics.drawable.Drawable 6 | import android.graphics.drawable.Icon 7 | import android.widget.ImageView 8 | import coil.imageLoader 9 | import coil.request.ImageRequest 10 | import coil.request.ImageResult 11 | import coil.transform.CircleCropTransformation 12 | import coil.transform.Transformation 13 | import coil.load as coilLoad 14 | 15 | interface ImageLoader { 16 | 17 | suspend fun load(url: String, transformation: Transformation? = null): Drawable? 18 | } 19 | 20 | interface IconLoader { 21 | 22 | suspend fun load(url: String): Icon? 23 | 24 | } 25 | 26 | 27 | class CachedIcons(private val imageLoader: ImageLoader) : IconLoader { 28 | 29 | private val circleCrop = CircleCropTransformation() 30 | private val cache = mutableMapOf() 31 | 32 | override suspend fun load(url: String): Icon? { 33 | return cache.getOrPut(url) { 34 | imageLoader.load(url, transformation = circleCrop)?.asBitmap()?.let { 35 | Icon.createWithBitmap(it) 36 | } 37 | } 38 | } 39 | } 40 | 41 | private fun Drawable.asBitmap() = (this as? BitmapDrawable)?.bitmap 42 | 43 | internal class CoilImageLoader(private val context: Context) : ImageLoader { 44 | 45 | private val coil = context.imageLoader 46 | 47 | override suspend fun load(url: String, transformation: Transformation?): Drawable? { 48 | return internalLoad(url, transformation).drawable 49 | } 50 | 51 | private suspend fun internalLoad(url: String, transformation: Transformation?): ImageResult { 52 | val request = ImageRequest.Builder(context) 53 | .data(url) 54 | .let { 55 | when (transformation) { 56 | null -> it 57 | else -> it.transformations(transformation) 58 | } 59 | } 60 | .build() 61 | return coil.execute(request) 62 | } 63 | } 64 | 65 | fun ImageView.load(url: String) { 66 | this.coilLoad(url) 67 | } -------------------------------------------------------------------------------- /domains/android/imageloader/src/main/kotlin/app/dapk/st/imageloader/ImageLoaderModule.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.imageloader 2 | 3 | import android.content.Context 4 | import app.dapk.st.core.extensions.unsafeLazy 5 | 6 | class ImageLoaderModule( 7 | private val context: Context, 8 | ) { 9 | 10 | private val imageLoader by unsafeLazy { CoilImageLoader(context) } 11 | 12 | private val cachedIcons by unsafeLazy { CachedIcons(imageLoader) } 13 | 14 | fun iconLoader(): IconLoader = cachedIcons 15 | 16 | } -------------------------------------------------------------------------------- /domains/android/push/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "st-android-library-conventions" 3 | alias libs.plugins.kotlin.serialization 4 | } 5 | 6 | dependencies { 7 | implementation "chat-engine:chat-engine" 8 | implementation project(':core') 9 | implementation project(':domains:store') 10 | implementation project(':domains:android:core') 11 | 12 | firebase(it, "messaging") 13 | 14 | implementation libs.kotlin.serialization 15 | implementation libs.unifiedpush 16 | 17 | kotlinTest(it) 18 | testImplementation 'chat-engine:chat-engine-test' 19 | androidImportFixturesWorkaround(project, project(":core")) 20 | androidImportFixturesWorkaround(project, project(":domains:android:stub")) 21 | } 22 | -------------------------------------------------------------------------------- /domains/android/push/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /domains/android/push/src/main/kotlin/app/dapk/st/push/PushHandler.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.push 2 | 3 | import app.dapk.st.matrix.common.EventId 4 | import app.dapk.st.matrix.common.RoomId 5 | import kotlinx.serialization.SerialName 6 | import kotlinx.serialization.Serializable 7 | 8 | @Serializable 9 | data class PushTokenPayload( 10 | @SerialName("token") val token: String, 11 | @SerialName("gateway_url") val gatewayUrl: String, 12 | ) 13 | 14 | interface PushHandler { 15 | fun onNewToken(payload: PushTokenPayload) 16 | fun onMessageReceived(eventId: EventId?, roomId: RoomId?) 17 | } -------------------------------------------------------------------------------- /domains/android/push/src/main/kotlin/app/dapk/st/push/PushModule.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.push 2 | 3 | import android.content.Context 4 | import app.dapk.st.core.CoroutineDispatchers 5 | import app.dapk.st.core.Preferences 6 | import app.dapk.st.core.ProvidableModule 7 | import app.dapk.st.core.extensions.ErrorTracker 8 | import app.dapk.st.core.extensions.unsafeLazy 9 | import app.dapk.st.domain.push.PushTokenRegistrarPreferences 10 | import app.dapk.st.firebase.messaging.Messaging 11 | import app.dapk.st.push.messaging.MessagingPushTokenRegistrar 12 | import app.dapk.st.push.unifiedpush.UnifiedPushImpl 13 | import app.dapk.st.push.unifiedpush.UnifiedPushRegistrar 14 | 15 | class PushModule( 16 | private val errorTracker: ErrorTracker, 17 | private val pushHandler: PushHandler, 18 | private val context: Context, 19 | private val dispatchers: CoroutineDispatchers, 20 | private val preferences: Preferences, 21 | private val messaging: Messaging, 22 | ) : ProvidableModule { 23 | 24 | private val registrars by unsafeLazy { 25 | val unifiedPush = UnifiedPushImpl(context) 26 | PushTokenRegistrars( 27 | MessagingPushTokenRegistrar( 28 | errorTracker, 29 | pushHandler, 30 | messaging, 31 | ), 32 | UnifiedPushRegistrar(context, unifiedPush), 33 | PushTokenRegistrarPreferences(preferences), 34 | ) 35 | } 36 | 37 | fun pushTokenRegistrars() = registrars 38 | 39 | fun pushTokenRegistrar(): PushTokenRegistrar = pushTokenRegistrars() 40 | fun pushHandler() = pushHandler 41 | fun dispatcher() = dispatchers 42 | 43 | } 44 | -------------------------------------------------------------------------------- /domains/android/push/src/main/kotlin/app/dapk/st/push/PushTokenRegistrar.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.push 2 | 3 | interface PushTokenRegistrar { 4 | suspend fun registerCurrentToken() 5 | fun unregister() 6 | } 7 | -------------------------------------------------------------------------------- /domains/android/push/src/main/kotlin/app/dapk/st/push/messaging/MessagingPushTokenRegistrar.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.push.messaging 2 | 3 | import app.dapk.st.core.AppLogTag 4 | import app.dapk.st.core.extensions.CrashScope 5 | import app.dapk.st.core.extensions.ErrorTracker 6 | import app.dapk.st.core.log 7 | import app.dapk.st.firebase.messaging.Messaging 8 | import app.dapk.st.push.PushHandler 9 | import app.dapk.st.push.PushTokenPayload 10 | import app.dapk.st.push.PushTokenRegistrar 11 | 12 | private const val SYGNAL_GATEWAY = "https://sygnal.dapk.app/_matrix/push/v1/notify" 13 | 14 | class MessagingPushTokenRegistrar( 15 | override val errorTracker: ErrorTracker, 16 | private val pushHandler: PushHandler, 17 | private val messaging: Messaging, 18 | ) : PushTokenRegistrar, CrashScope { 19 | 20 | override suspend fun registerCurrentToken() { 21 | log(AppLogTag.PUSH, "FCM - register current token") 22 | messaging.enable() 23 | 24 | kotlin.runCatching { 25 | messaging.token().also { 26 | pushHandler.onNewToken( 27 | PushTokenPayload( 28 | token = it, 29 | gatewayUrl = SYGNAL_GATEWAY, 30 | ) 31 | ) 32 | } 33 | } 34 | .trackFailure() 35 | .onSuccess { 36 | log(AppLogTag.PUSH, "registered new push token") 37 | } 38 | } 39 | 40 | override fun unregister() { 41 | log(AppLogTag.PUSH, "FCM - unregister") 42 | messaging.deleteToken() 43 | messaging.disable() 44 | } 45 | 46 | fun isAvailable() = messaging.isAvailable() 47 | 48 | } -------------------------------------------------------------------------------- /domains/android/push/src/main/kotlin/app/dapk/st/push/messaging/MessagingServiceAdapter.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.push.messaging 2 | 3 | import app.dapk.st.core.AppLogTag 4 | import app.dapk.st.core.log 5 | import app.dapk.st.firebase.messaging.ServiceDelegate 6 | import app.dapk.st.matrix.common.EventId 7 | import app.dapk.st.matrix.common.RoomId 8 | import app.dapk.st.push.PushHandler 9 | import app.dapk.st.push.PushTokenPayload 10 | 11 | private const val SYGNAL_GATEWAY = "https://sygnal.dapk.app/_matrix/push/v1/notify" 12 | 13 | class MessagingServiceAdapter( 14 | private val handler: PushHandler, 15 | ) : ServiceDelegate { 16 | 17 | override fun onNewToken(token: String) { 18 | log(AppLogTag.PUSH, "FCM onNewToken") 19 | handler.onNewToken( 20 | PushTokenPayload( 21 | token = token, 22 | gatewayUrl = SYGNAL_GATEWAY, 23 | ) 24 | ) 25 | } 26 | 27 | override fun onMessageReceived(eventId: EventId?, roomId: RoomId?) { 28 | log(AppLogTag.PUSH, "FCM onMessage") 29 | handler.onMessageReceived(eventId, roomId) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPush.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.push.unifiedpush 2 | 3 | import android.content.Context 4 | import org.unifiedpush.android.connector.UnifiedPush 5 | 6 | interface UnifiedPush { 7 | fun saveDistributor(distributor: String) 8 | fun getDistributor(): String 9 | fun getDistributors(): List 10 | fun registerApp() 11 | fun unregisterApp() 12 | } 13 | 14 | internal class UnifiedPushImpl(private val context: Context) : app.dapk.st.push.unifiedpush.UnifiedPush { 15 | override fun saveDistributor(distributor: String) = UnifiedPush.saveDistributor(context, distributor) 16 | override fun getDistributor(): String = UnifiedPush.getDistributor(context) 17 | override fun getDistributors(): List = UnifiedPush.getDistributors(context) 18 | override fun registerApp() = UnifiedPush.registerApp(context) 19 | override fun unregisterApp() = UnifiedPush.unregisterApp(context) 20 | } -------------------------------------------------------------------------------- /domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushMessageReceiver.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.push.unifiedpush 2 | 3 | import android.content.Context 4 | import app.dapk.st.core.AppLogTag 5 | import app.dapk.st.core.log 6 | import kotlinx.serialization.json.Json 7 | import org.unifiedpush.android.connector.MessagingReceiver 8 | 9 | class UnifiedPushMessageReceiver : MessagingReceiver() { 10 | 11 | private val delegate = UnifiedPushMessageDelegate() 12 | 13 | override fun onMessage(context: Context, message: ByteArray, instance: String) { 14 | delegate.onMessage(context, message) 15 | } 16 | 17 | override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { 18 | delegate.onNewEndpoint(context, endpoint) 19 | } 20 | 21 | override fun onRegistrationFailed(context: Context, instance: String) { 22 | log(AppLogTag.PUSH, "UnifiedPush onRegistrationFailed") 23 | } 24 | 25 | override fun onUnregistered(context: Context, instance: String) { 26 | log(AppLogTag.PUSH, "UnifiedPush onUnregistered") 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushRegistrar.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.push.unifiedpush 2 | 3 | import android.content.ComponentName 4 | import android.content.Context 5 | import android.content.pm.PackageManager 6 | import app.dapk.st.core.AppLogTag 7 | import app.dapk.st.core.log 8 | import app.dapk.st.push.PushTokenRegistrar 9 | import app.dapk.st.push.Registrar 10 | 11 | class UnifiedPushRegistrar( 12 | private val context: Context, 13 | private val unifiedPush: UnifiedPush, 14 | private val componentFactory: (Context) -> ComponentName = { ComponentName(it, UnifiedPushMessageReceiver::class.java) } 15 | ) : PushTokenRegistrar { 16 | 17 | fun getDistributors() = unifiedPush.getDistributors().map { Registrar(it) } 18 | 19 | fun registerSelection(registrar: Registrar) { 20 | log(AppLogTag.PUSH, "UnifiedPush - register: $registrar") 21 | unifiedPush.saveDistributor(registrar.id) 22 | registerApp() 23 | } 24 | 25 | override suspend fun registerCurrentToken() { 26 | log(AppLogTag.PUSH, "UnifiedPush - register current token") 27 | if (unifiedPush.getDistributor().isNotEmpty()) { 28 | registerApp() 29 | } 30 | } 31 | 32 | private fun registerApp() { 33 | context.packageManager.setComponentEnabledSetting( 34 | componentFactory(context), 35 | PackageManager.COMPONENT_ENABLED_STATE_ENABLED, 36 | PackageManager.DONT_KILL_APP, 37 | ) 38 | unifiedPush.registerApp() 39 | } 40 | 41 | override fun unregister() { 42 | unifiedPush.unregisterApp() 43 | context.packageManager.setComponentEnabledSetting( 44 | componentFactory(context), 45 | PackageManager.COMPONENT_ENABLED_STATE_DISABLED, 46 | PackageManager.DONT_KILL_APP, 47 | ) 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /domains/android/push/src/test/kotlin/app/dapk/st/push/messaging/MessagingServiceAdapterTest.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.push.messaging 2 | 3 | import app.dapk.st.push.PushTokenPayload 4 | import app.dapk.st.push.unifiedpush.FakePushHandler 5 | import fixture.aRoomId 6 | import fixture.anEventId 7 | import org.junit.Test 8 | import test.runExpectTest 9 | 10 | private const val A_TOKEN = "a-push-token" 11 | private const val SYGNAL_GATEWAY = "https://sygnal.dapk.app/_matrix/push/v1/notify" 12 | private val A_ROOM_ID = aRoomId() 13 | private val AN_EVENT_ID = anEventId() 14 | 15 | class MessagingServiceAdapterTest { 16 | 17 | private val fakePushHandler = FakePushHandler() 18 | 19 | private val messagingServiceAdapter = MessagingServiceAdapter(fakePushHandler) 20 | 21 | @Test 22 | fun `onNewToken, then delegates to push handler`() = runExpectTest { 23 | fakePushHandler.expect { 24 | it.onNewToken(PushTokenPayload(token = A_TOKEN, gatewayUrl = SYGNAL_GATEWAY)) 25 | } 26 | messagingServiceAdapter.onNewToken(A_TOKEN) 27 | 28 | verifyExpects() 29 | } 30 | 31 | 32 | @Test 33 | fun `onMessageReceived, then delegates to push handler`() = runExpectTest { 34 | fakePushHandler.expect { 35 | it.onMessageReceived(AN_EVENT_ID, A_ROOM_ID) 36 | } 37 | messagingServiceAdapter.onMessageReceived(AN_EVENT_ID, A_ROOM_ID) 38 | 39 | verifyExpects() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /domains/android/push/src/test/kotlin/app/dapk/st/push/unifiedpush/FakePushHandler.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.push.unifiedpush 2 | 3 | import app.dapk.st.push.PushHandler 4 | import io.mockk.mockk 5 | 6 | class FakePushHandler : PushHandler by mockk() -------------------------------------------------------------------------------- /domains/android/stub/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "kotlin" 3 | id 'java-test-fixtures' 4 | } 5 | 6 | def properties = new Properties() 7 | def localProperties = rootProject.file("local.properties") 8 | if (localProperties.exists()) { 9 | properties.load(rootProject.file("local.properties").newDataInputStream()) 10 | } else { 11 | properties.setProperty("sdk.dir", System.getenv("ANDROID_HOME")) 12 | } 13 | 14 | dependencies { 15 | def androidVer = 33 16 | api files("${properties.getProperty("sdk.dir")}/platforms/android-${androidVer}/android.jar") 17 | 18 | kotlinFixtures(it) 19 | testFixturesImplementation testFixtures(project(":core")) 20 | testFixturesImplementation files("${properties.getProperty("sdk.dir")}/platforms/android-${androidVer}/android.jar") 21 | } -------------------------------------------------------------------------------- /domains/android/stub/src/testFixtures/kotlin/fake/FakeContentResolver.kt: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import android.content.ContentResolver 4 | import android.net.Uri 5 | import io.mockk.every 6 | import io.mockk.mockk 7 | import test.delegateReturn 8 | 9 | class FakeContentResolver { 10 | 11 | val instance = mockk() 12 | 13 | fun givenFile(uri: Uri) = every { instance.openInputStream(uri) }.delegateReturn() 14 | 15 | fun givenUriResult(uri: Uri) = every { instance.query(uri, null, null, null, null) }.delegateReturn() 16 | 17 | fun givenQueryResult( 18 | uri: Uri, 19 | projection: Array?, 20 | selection: String?, 21 | selectionArgs: Array?, 22 | sortOrder: String?, 23 | ) = every { 24 | instance.query( 25 | uri, 26 | projection, 27 | selection, 28 | selectionArgs, 29 | sortOrder 30 | ) 31 | }.delegateReturn() 32 | } 33 | -------------------------------------------------------------------------------- /domains/android/stub/src/testFixtures/kotlin/fake/FakeContext.kt: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import android.content.Context 4 | import android.content.pm.PackageManager 5 | import io.mockk.every 6 | import io.mockk.mockk 7 | import test.delegateReturn 8 | 9 | class FakeContext { 10 | val instance = mockk() 11 | fun givenPackageManager() = every { instance.packageManager }.delegateReturn() 12 | } 13 | 14 | class FakePackageManager { 15 | val instance = mockk() 16 | } -------------------------------------------------------------------------------- /domains/android/stub/src/testFixtures/kotlin/fake/FakeInboxStyle.kt: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import android.app.Notification 4 | import io.mockk.every 5 | import io.mockk.mockk 6 | import io.mockk.slot 7 | 8 | class FakeInboxStyle { 9 | private val _summary = slot() 10 | 11 | val instance = mockk() 12 | val lines = mutableListOf() 13 | val summary: String 14 | get() = _summary.captured 15 | 16 | fun captureInteractions() { 17 | every { instance.addLine(capture(lines)) } returns instance 18 | every { instance.setSummaryText(capture(_summary)) } returns instance 19 | } 20 | 21 | 22 | } -------------------------------------------------------------------------------- /domains/android/stub/src/testFixtures/kotlin/fake/FakeMessagingStyle.kt: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import android.app.Notification 4 | import android.app.Person 5 | import io.mockk.every 6 | import io.mockk.mockk 7 | 8 | class FakeMessagingStyle { 9 | var user: Person? = null 10 | val instance = mockk() 11 | 12 | } 13 | 14 | fun aFakeMessagingStyle() = FakeMessagingStyle().instance -------------------------------------------------------------------------------- /domains/android/stub/src/testFixtures/kotlin/fake/FakeNotification.kt: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import android.app.Notification 4 | import io.mockk.mockk 5 | 6 | class FakeNotification { 7 | 8 | val instance = mockk() 9 | 10 | } 11 | 12 | fun aFakeNotification() = FakeNotification().instance -------------------------------------------------------------------------------- /domains/android/stub/src/testFixtures/kotlin/fake/FakeNotificationBuilder.kt: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import android.app.Notification 4 | import io.mockk.every 5 | import io.mockk.mockk 6 | import test.delegateReturn 7 | 8 | class FakeNotificationBuilder { 9 | val instance = mockk(relaxed = true) 10 | 11 | fun givenBuilds() = every { instance.build() }.delegateReturn() 12 | } -------------------------------------------------------------------------------- /domains/android/stub/src/testFixtures/kotlin/fake/FakeNotificationManager.kt: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import android.app.NotificationManager 4 | import io.mockk.mockk 5 | import io.mockk.verify 6 | 7 | class FakeNotificationManager { 8 | 9 | val instance = mockk() 10 | 11 | fun verifyCancelled(tag: String, id: Int) { 12 | verify { instance.cancel(tag, id) } 13 | } 14 | } -------------------------------------------------------------------------------- /domains/android/stub/src/testFixtures/kotlin/fake/FakePersonBuilder.kt: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import android.app.Person 4 | import io.mockk.mockk 5 | 6 | class FakePersonBuilder { 7 | val instance = mockk(relaxed = true) 8 | } -------------------------------------------------------------------------------- /domains/android/stub/src/testFixtures/kotlin/fake/FakeUri.kt: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import android.net.Uri 4 | import io.mockk.every 5 | import io.mockk.mockk 6 | 7 | class FakeUri { 8 | val instance = mockk() 9 | 10 | fun givenNonHierarchical() { 11 | givenContent(schema = "mail", path = null) 12 | } 13 | 14 | fun givenContent(schema: String, path: String?) { 15 | every { instance.scheme } returns schema 16 | every { instance.path } returns path 17 | } 18 | } -------------------------------------------------------------------------------- /domains/android/tracking/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "st-android-library-conventions" 3 | } 4 | 5 | dependencies { 6 | implementation project(':core') 7 | firebase(it, "crashlytics") 8 | } 9 | -------------------------------------------------------------------------------- /domains/android/tracking/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /domains/android/tracking/src/main/kotlin/app/dapk/st/tracking/CrashTrackerLogger.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.tracking 2 | 3 | import android.util.Log 4 | import app.dapk.st.core.AppLogTag 5 | import app.dapk.st.core.extensions.ErrorTracker 6 | import app.dapk.st.core.log 7 | 8 | class CrashTrackerLogger : ErrorTracker { 9 | 10 | override fun track(throwable: Throwable, extra: String) { 11 | Log.e("ST", throwable.message, throwable) 12 | log(AppLogTag.ERROR_NON_FATAL, "${throwable.message ?: "N/A"} extra=$extra") 13 | 14 | throwable.findCauseMessage()?.let { 15 | if (throwable.message != it) { 16 | log(AppLogTag.ERROR_NON_FATAL, it) 17 | } 18 | } 19 | } 20 | } 21 | 22 | private fun Throwable.findCauseMessage(): String? { 23 | return when (val inner = this.cause) { 24 | null -> this.message ?: "" 25 | else -> inner.findCauseMessage() 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /domains/android/tracking/src/main/kotlin/app/dapk/st/tracking/TrackingModule.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.tracking 2 | 3 | import app.dapk.st.core.extensions.ErrorTracker 4 | import app.dapk.st.core.extensions.unsafeLazy 5 | import app.dapk.st.firebase.crashlytics.CrashlyticsModule 6 | 7 | class TrackingModule( 8 | private val isCrashTrackingEnabled: Boolean, 9 | ) { 10 | 11 | val errorTracker: ErrorTracker by unsafeLazy { 12 | when (isCrashTrackingEnabled) { 13 | true -> compositeTracker( 14 | CrashTrackerLogger(), 15 | CrashlyticsModule().errorTracker, 16 | ) 17 | false -> CrashTrackerLogger() 18 | } 19 | } 20 | 21 | } 22 | 23 | private fun compositeTracker(vararg loggers: ErrorTracker) = object : ErrorTracker { 24 | override fun track(throwable: Throwable, extra: String) { 25 | loggers.forEach { it.track(throwable, extra) } 26 | } 27 | } -------------------------------------------------------------------------------- /domains/android/viewmodel-stub/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "kotlin" 3 | } 4 | -------------------------------------------------------------------------------- /domains/android/viewmodel-stub/src/main/kotlin/androidx/compose/runtime/MutableState.kt: -------------------------------------------------------------------------------- 1 | @file:JvmName("SnapshotStateKt") 2 | @file:Suppress("UNUSED") 3 | package androidx.compose.runtime 4 | 5 | import kotlin.reflect.KProperty 6 | 7 | interface State { 8 | val value: T 9 | } 10 | 11 | interface MutableState : State { 12 | override var value: T 13 | operator fun component1(): T 14 | operator fun component2(): (T) -> Unit 15 | } 16 | 17 | operator fun State.getValue(thisObj: Any?, property: KProperty<*>): T = throw RuntimeException("stub") 18 | operator fun MutableState.setValue(thisObj: Any?, property: KProperty<*>, value: T) { 19 | throw RuntimeException("stub") 20 | } 21 | 22 | fun mutableStateOf( 23 | value: T, 24 | policy: SnapshotMutationPolicy = object : SnapshotMutationPolicy { 25 | override fun equivalent(a: T, b: T): Boolean { 26 | throw RuntimeException("stub") 27 | } 28 | } 29 | ): MutableState = throw RuntimeException("stub") 30 | 31 | interface SnapshotMutationPolicy { 32 | fun equivalent(a: T, b: T): Boolean 33 | fun merge(previous: T, current: T, applied: T): T? = null 34 | } 35 | -------------------------------------------------------------------------------- /domains/android/viewmodel-stub/src/main/kotlin/androidx/lifecycle/ViewModel.kt: -------------------------------------------------------------------------------- 1 | package androidx.lifecycle 2 | 3 | abstract class ViewModel { 4 | 5 | protected open fun onCleared() {} 6 | 7 | fun clear() { 8 | throw RuntimeException("stub") 9 | } 10 | 11 | fun setTagIfAbsent(key: String, newValue: T): T { 12 | throw RuntimeException("stub") 13 | } 14 | 15 | fun getTag(key: String): T? { 16 | throw RuntimeException("stub") 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /domains/android/viewmodel/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "kotlin" 3 | id 'java-test-fixtures' 4 | } 5 | 6 | dependencies { 7 | compileOnly project(":domains:android:viewmodel-stub") 8 | implementation libs.kotlin.coroutines 9 | 10 | kotlinFixtures(it) 11 | testFixturesImplementation libs.kotlin.coroutines 12 | testFixturesImplementation libs.kotlin.coroutines.test 13 | testFixturesImplementation testFixtures(project(":core")) 14 | testFixturesCompileOnly project(":domains:android:viewmodel-stub") 15 | } -------------------------------------------------------------------------------- /domains/android/viewmodel/src/main/kotlin/app/dapk/st/viewmodel/DapkViewModel.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.viewmodel 2 | 3 | import androidx.compose.runtime.MutableState 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.setValue 7 | import androidx.lifecycle.ViewModel 8 | import kotlinx.coroutines.flow.MutableSharedFlow 9 | import kotlinx.coroutines.flow.SharedFlow 10 | 11 | typealias MutableStateFactory = (S) -> MutableState 12 | 13 | fun defaultStateFactory(): MutableStateFactory = { mutableStateOf(it) } 14 | 15 | @Suppress("PropertyName") 16 | abstract class DapkViewModel(initialState: S, factory: MutableStateFactory = defaultStateFactory()) : ViewModel() { 17 | 18 | protected val _events = MutableSharedFlow(extraBufferCapacity = 1) 19 | val events: SharedFlow = _events 20 | 21 | var state by factory(initialState) 22 | protected set 23 | 24 | fun updateState(reducer: S.() -> S) { 25 | state = reducer(state) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /domains/android/viewmodel/src/testFixtures/kotlin/TestMutableState.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.runtime.MutableState 2 | 3 | class TestMutableState(initialState: T) : MutableState { 4 | 5 | private var _value: T = initialState 6 | 7 | var onValue: ((T) -> Unit)? = null 8 | 9 | override var value: T 10 | get() = _value 11 | set(value) { 12 | _value = value 13 | onValue?.invoke(value) 14 | } 15 | 16 | override fun component1(): T = throw RuntimeException("stub") 17 | override fun component2(): (T) -> Unit = throw RuntimeException("stub") 18 | } -------------------------------------------------------------------------------- /domains/android/viewmodel/src/testFixtures/kotlin/ViewModelTest.kt: -------------------------------------------------------------------------------- 1 | import app.dapk.st.viewmodel.MutableStateFactory 2 | import kotlinx.coroutines.Dispatchers 3 | import kotlinx.coroutines.test.UnconfinedTestDispatcher 4 | import kotlinx.coroutines.test.resetMain 5 | import kotlinx.coroutines.test.runTest 6 | import kotlinx.coroutines.test.setMain 7 | import test.ExpectTest 8 | 9 | @Suppress("UNCHECKED_CAST") 10 | class ViewModelTest { 11 | 12 | var instance: TestMutableState? = null 13 | 14 | fun testMutableStateFactory(): MutableStateFactory { 15 | return { TestMutableState(it).also { instance = it as TestMutableState } } 16 | } 17 | 18 | operator fun invoke(block: suspend ViewModelTestScope.() -> Unit) { 19 | runTest { 20 | val expectTest = ExpectTest(coroutineContext) 21 | val viewModelTest = ViewModelTestScopeImpl(expectTest, this@ViewModelTest) 22 | Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) 23 | block(viewModelTest) 24 | viewModelTest.finish() 25 | Dispatchers.resetMain() 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /domains/android/work/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "st-android-library-conventions" 3 | } 4 | 5 | dependencies { 6 | implementation project(':core') 7 | implementation project(':domains:android:core') 8 | } 9 | -------------------------------------------------------------------------------- /domains/android/work/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /domains/android/work/src/main/kotlin/app/dapk/st/work/TaskRunner.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.work 2 | 3 | import android.app.job.JobWorkItem 4 | import app.dapk.st.work.WorkScheduler.WorkTask 5 | 6 | interface TaskRunner { 7 | 8 | suspend fun run(tasks: List): List 9 | 10 | data class RunnableWorkTask( 11 | val source: JobWorkItem?, 12 | val task: WorkTask 13 | ) 14 | 15 | sealed interface TaskResult { 16 | val source: JobWorkItem? 17 | 18 | data class Success(override val source: JobWorkItem?) : TaskResult 19 | data class Failure(override val source: JobWorkItem?, val canRetry: Boolean) : TaskResult 20 | } 21 | 22 | } -------------------------------------------------------------------------------- /domains/android/work/src/main/kotlin/app/dapk/st/work/WorkModule.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.work 2 | 3 | import android.content.Context 4 | import app.dapk.st.core.ProvidableModule 5 | 6 | class WorkModule(private val context: Context) { 7 | fun workScheduler(): WorkScheduler = WorkSchedulingJobScheduler(context) 8 | } 9 | 10 | class TaskRunnerModule(private val taskRunner: TaskRunner) : ProvidableModule { 11 | fun taskRunner() = taskRunner 12 | } -------------------------------------------------------------------------------- /domains/android/work/src/main/kotlin/app/dapk/st/work/WorkScheduler.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.work 2 | 3 | interface WorkScheduler { 4 | 5 | fun schedule(task: WorkTask) 6 | 7 | data class WorkTask(val jobId: Int, val type: String, val jsonPayload: String) 8 | 9 | } -------------------------------------------------------------------------------- /domains/android/work/src/main/kotlin/app/dapk/st/work/WorkSchedulingJobScheduler.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.work 2 | 3 | import android.app.job.JobInfo 4 | import android.app.job.JobScheduler 5 | import android.app.job.JobWorkItem 6 | import android.content.ComponentName 7 | import android.content.Context 8 | import android.content.Intent 9 | import android.os.Build 10 | 11 | internal class WorkSchedulingJobScheduler( 12 | private val context: Context, 13 | ) : WorkScheduler { 14 | 15 | private val jobScheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler 16 | 17 | override fun schedule(task: WorkScheduler.WorkTask) { 18 | val job = JobInfo.Builder(100, ComponentName(context, WorkAndroidService::class.java)) 19 | .setMinimumLatency(1) 20 | .setOverrideDeadline(1) 21 | .setBackoffCriteria(1000L, JobInfo.BACKOFF_POLICY_EXPONENTIAL) 22 | .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) 23 | .setRequiresCharging(false) 24 | .setRequiresDeviceIdle(false) 25 | .build() 26 | 27 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 28 | val item = JobWorkItem( 29 | Intent() 30 | .putExtra("task-type", task.type) 31 | .putExtra("task-payload", task.jsonPayload) 32 | ) 33 | jobScheduler.enqueue(job, item) 34 | } else { 35 | job.extras.putString("task-type", task.type) 36 | job.extras.putString("task-payload", task.jsonPayload) 37 | jobScheduler.schedule(job) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /domains/firebase/crashlytics-noop/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "kotlin" 3 | } 4 | 5 | dependencies { 6 | implementation project(':core') 7 | } 8 | 9 | 10 | task generateReleaseSources {} 11 | task compileReleaseSources {} -------------------------------------------------------------------------------- /domains/firebase/crashlytics-noop/src/main/kotlin/app/dapk/st/firebase/crashlytics/CrashlyticsModule.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.firebase.crashlytics 2 | 3 | import app.dapk.st.core.extensions.ErrorTracker 4 | import app.dapk.st.core.extensions.unsafeLazy 5 | 6 | class CrashlyticsModule { 7 | 8 | val errorTracker: ErrorTracker by unsafeLazy { 9 | object : ErrorTracker { 10 | override fun track(throwable: Throwable, extra: String) { 11 | // no op 12 | } 13 | } 14 | } 15 | 16 | } -------------------------------------------------------------------------------- /domains/firebase/crashlytics/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "st-android-library-conventions" 3 | } 4 | 5 | dependencies { 6 | implementation project(':core') 7 | implementation platform(libs.firebase.bom) 8 | implementation 'com.google.firebase:firebase-crashlytics' 9 | } 10 | -------------------------------------------------------------------------------- /domains/firebase/crashlytics/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /domains/firebase/crashlytics/src/main/kotlin/app/dapk/st/firebase/crashlytics/CrashlyticsCrashTracker.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.firebase.crashlytics 2 | 3 | import app.dapk.st.core.extensions.ErrorTracker 4 | import com.google.firebase.crashlytics.FirebaseCrashlytics 5 | 6 | class CrashlyticsCrashTracker( 7 | private val firebaseCrashlytics: FirebaseCrashlytics, 8 | ) : ErrorTracker { 9 | 10 | override fun track(throwable: Throwable, extra: String) { 11 | firebaseCrashlytics.recordException(throwable) 12 | } 13 | } 14 | 15 | -------------------------------------------------------------------------------- /domains/firebase/crashlytics/src/main/kotlin/app/dapk/st/firebase/crashlytics/CrashlyticsModule.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.firebase.crashlytics 2 | 3 | import app.dapk.st.core.extensions.ErrorTracker 4 | import app.dapk.st.core.extensions.unsafeLazy 5 | import com.google.firebase.crashlytics.FirebaseCrashlytics 6 | 7 | class CrashlyticsModule { 8 | 9 | val errorTracker: ErrorTracker by unsafeLazy { 10 | CrashlyticsCrashTracker(FirebaseCrashlytics.getInstance()) 11 | } 12 | 13 | } -------------------------------------------------------------------------------- /domains/firebase/messaging-noop/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "st-android-library-conventions" 3 | } 4 | 5 | dependencies { 6 | implementation project(':core') 7 | implementation "chat-engine:chat-engine" 8 | } 9 | -------------------------------------------------------------------------------- /domains/firebase/messaging-noop/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /domains/firebase/messaging-noop/src/main/kotlin/app/dapk/st/firebase/messaging/Messaging.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.firebase.messaging 2 | 3 | class Messaging { 4 | 5 | fun isAvailable() = false 6 | 7 | fun enable() { 8 | // do nothing 9 | } 10 | 11 | fun disable() { 12 | // do nothing 13 | } 14 | 15 | fun deleteToken() { 16 | // do nothing 17 | } 18 | 19 | suspend fun token(): String { 20 | return "" 21 | } 22 | 23 | } -------------------------------------------------------------------------------- /domains/firebase/messaging-noop/src/main/kotlin/app/dapk/st/firebase/messaging/MessagingModule.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.firebase.messaging 2 | 3 | import android.content.Context 4 | import app.dapk.st.core.ProvidableModule 5 | import app.dapk.st.core.extensions.unsafeLazy 6 | 7 | @Suppress("UNUSED") 8 | class MessagingModule( 9 | val serviceDelegate: ServiceDelegate, 10 | val context: Context, 11 | ) : ProvidableModule { 12 | 13 | val messaging by unsafeLazy { 14 | Messaging() 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /domains/firebase/messaging-noop/src/main/kotlin/app/dapk/st/firebase/messaging/ServiceDelegate.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.firebase.messaging 2 | 3 | import app.dapk.st.matrix.common.EventId 4 | import app.dapk.st.matrix.common.RoomId 5 | 6 | interface ServiceDelegate { 7 | fun onNewToken(token: String) 8 | fun onMessageReceived(eventId: EventId?, roomId: RoomId?) 9 | } -------------------------------------------------------------------------------- /domains/firebase/messaging/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "st-android-library-conventions" 3 | } 4 | 5 | dependencies { 6 | implementation project(':core') 7 | implementation project(':domains:android:core') 8 | implementation "chat-engine:chat-engine" 9 | implementation platform(libs.firebase.bom) 10 | implementation 'com.google.firebase:firebase-messaging' 11 | } 12 | -------------------------------------------------------------------------------- /domains/firebase/messaging/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /domains/firebase/messaging/src/main/kotlin/app/dapk/st/firebase/messaging/FirebasePushServiceDelegate.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.firebase.messaging 2 | 3 | import app.dapk.st.core.AppLogTag 4 | import app.dapk.st.core.extensions.unsafeLazy 5 | import app.dapk.st.core.log 6 | import app.dapk.st.core.module 7 | import app.dapk.st.matrix.common.EventId 8 | import app.dapk.st.matrix.common.RoomId 9 | import com.google.firebase.messaging.FirebaseMessagingService 10 | import com.google.firebase.messaging.RemoteMessage 11 | 12 | class FirebasePushServiceDelegate : FirebaseMessagingService() { 13 | 14 | private val delegate by unsafeLazy { module().serviceDelegate } 15 | 16 | override fun onNewToken(token: String) { 17 | delegate.onNewToken(token) 18 | } 19 | 20 | override fun onMessageReceived(message: RemoteMessage) { 21 | log(AppLogTag.PUSH, "FCM onMessage") 22 | val eventId = message.data["event_id"]?.let { EventId(it) } 23 | val roomId = message.data["room_id"]?.let { RoomId(it) } 24 | delegate.onMessageReceived(eventId, roomId) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /domains/firebase/messaging/src/main/kotlin/app/dapk/st/firebase/messaging/Messaging.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.firebase.messaging 2 | 3 | import android.content.ComponentName 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.content.pm.PackageManager 7 | import com.google.android.gms.common.ConnectionResult 8 | import com.google.android.gms.common.GoogleApiAvailabilityLight 9 | import com.google.firebase.messaging.FirebaseMessaging 10 | import kotlin.coroutines.resume 11 | import kotlin.coroutines.suspendCoroutine 12 | 13 | class Messaging( 14 | private val instance: FirebaseMessaging, 15 | private val context: Context, 16 | ) { 17 | 18 | fun isAvailable() = GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS 19 | 20 | fun enable() { 21 | context.packageManager.setComponentEnabledSetting( 22 | ComponentName(context, FirebasePushServiceDelegate::class.java), 23 | PackageManager.COMPONENT_ENABLED_STATE_ENABLED, 24 | PackageManager.DONT_KILL_APP, 25 | ) 26 | } 27 | 28 | fun disable() { 29 | context.stopService(Intent(context, FirebasePushServiceDelegate::class.java)) 30 | context.packageManager.setComponentEnabledSetting( 31 | ComponentName(context, FirebasePushServiceDelegate::class.java), 32 | PackageManager.COMPONENT_ENABLED_STATE_DISABLED, 33 | PackageManager.DONT_KILL_APP, 34 | ) 35 | 36 | } 37 | 38 | fun deleteToken() { 39 | instance.deleteToken() 40 | } 41 | 42 | suspend fun token() = suspendCoroutine { continuation -> 43 | instance.token.addOnCompleteListener { task -> 44 | when { 45 | task.isSuccessful -> continuation.resume(task.result!!) 46 | task.isCanceled -> continuation.resumeWith(Result.failure(CancelledTokenFetchingException())) 47 | else -> continuation.resumeWith(Result.failure(task.exception ?: UnknownTokenFetchingFailedException())) 48 | } 49 | } 50 | } 51 | 52 | private class CancelledTokenFetchingException : Throwable() 53 | private class UnknownTokenFetchingFailedException : Throwable() 54 | } -------------------------------------------------------------------------------- /domains/firebase/messaging/src/main/kotlin/app/dapk/st/firebase/messaging/MessagingModule.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.firebase.messaging 2 | 3 | import android.content.Context 4 | import app.dapk.st.core.ProvidableModule 5 | import app.dapk.st.core.extensions.unsafeLazy 6 | import com.google.firebase.messaging.FirebaseMessaging 7 | 8 | class MessagingModule( 9 | val serviceDelegate: ServiceDelegate, 10 | val context: Context, 11 | ) : ProvidableModule { 12 | 13 | val messaging by unsafeLazy { 14 | Messaging(FirebaseMessaging.getInstance(), context) 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /domains/firebase/messaging/src/main/kotlin/app/dapk/st/firebase/messaging/ServiceDelegate.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.firebase.messaging 2 | 3 | import app.dapk.st.matrix.common.EventId 4 | import app.dapk.st.matrix.common.RoomId 5 | 6 | interface ServiceDelegate { 7 | fun onNewToken(token: String) 8 | fun onMessageReceived(eventId: EventId?, roomId: RoomId?) 9 | } -------------------------------------------------------------------------------- /domains/store/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'kotlin' 3 | alias libs.plugins.kotlin.serialization 4 | alias libs.plugins.sqldelight 5 | id 'java-test-fixtures' 6 | } 7 | 8 | sqldelight { 9 | StDb { 10 | packageName = "app.dapk.db.app" 11 | } 12 | linkSqlite = true 13 | } 14 | 15 | dependencies { 16 | implementation project(":core") 17 | implementation "chat-engine:chat-engine" 18 | implementation libs.kotlin.serialization 19 | implementation libs.sqldelight.extensions 20 | 21 | kotlinFixtures(it) 22 | testImplementation(testFixtures(project(":core"))) 23 | testFixturesImplementation(testFixtures(project(":core")))} -------------------------------------------------------------------------------- /domains/store/src/main/kotlin/app/dapk/st/domain/ApplicationPreferences.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.domain 2 | 3 | import app.dapk.st.core.Preferences 4 | 5 | class ApplicationPreferences( 6 | private val preferences: Preferences, 7 | ) { 8 | 9 | suspend fun readVersion(): ApplicationVersion? { 10 | return preferences.readString("version")?.let { ApplicationVersion(it.toInt()) } 11 | } 12 | 13 | suspend fun setVersion(version: ApplicationVersion) { 14 | return preferences.store("version", version.value.toString()) 15 | } 16 | 17 | } 18 | 19 | data class ApplicationVersion(val value: Int) 20 | 21 | -------------------------------------------------------------------------------- /domains/store/src/main/kotlin/app/dapk/st/domain/DatabaseDropper.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.domain 2 | 3 | fun interface DatabaseDropper { 4 | suspend fun dropAllTables(includeCryptoAccount: Boolean) 5 | } -------------------------------------------------------------------------------- /domains/store/src/main/kotlin/app/dapk/st/domain/StoreCleaner.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.domain 2 | 3 | fun interface StoreCleaner { 4 | suspend fun cleanCache(removeCredentials: Boolean) 5 | } -------------------------------------------------------------------------------- /domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.domain 2 | 3 | import app.dapk.db.app.StDb 4 | import app.dapk.st.core.CoroutineDispatchers 5 | import app.dapk.st.core.Preferences 6 | import app.dapk.st.domain.application.eventlog.EventLogPersistence 7 | import app.dapk.st.domain.application.eventlog.LoggingStore 8 | import app.dapk.st.domain.application.message.MessageOptionsStore 9 | import app.dapk.st.domain.preference.CachingPreferences 10 | import app.dapk.st.domain.preference.PropertyCache 11 | import app.dapk.st.domain.push.PushTokenRegistrarPreferences 12 | 13 | class StoreModule( 14 | private val database: StDb, 15 | private val databaseDropper: DatabaseDropper, 16 | val preferences: Preferences, 17 | val credentialPreferences: Preferences, 18 | private val coroutineDispatchers: CoroutineDispatchers, 19 | ) { 20 | 21 | private val cache = PropertyCache() 22 | val cachingPreferences = CachingPreferences(cache, preferences) 23 | 24 | fun pushStore() = PushTokenRegistrarPreferences(preferences) 25 | 26 | fun applicationStore() = ApplicationPreferences(preferences) 27 | 28 | fun cacheCleaner() = StoreCleaner { cleanCredentials -> 29 | if (cleanCredentials) { 30 | credentialPreferences.clear() 31 | } 32 | preferences.clear() 33 | databaseDropper.dropAllTables(includeCryptoAccount = cleanCredentials) 34 | } 35 | 36 | fun eventLogStore(): EventLogPersistence { 37 | return EventLogPersistence(database, coroutineDispatchers) 38 | } 39 | 40 | fun loggingStore(): LoggingStore = LoggingStore(cachingPreferences) 41 | 42 | fun messageStore(): MessageOptionsStore = MessageOptionsStore(cachingPreferences) 43 | 44 | } 45 | -------------------------------------------------------------------------------- /domains/store/src/main/kotlin/app/dapk/st/domain/application/eventlog/LoggingStore.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.domain.application.eventlog 2 | 3 | import app.dapk.st.core.CachedPreferences 4 | import app.dapk.st.core.readBoolean 5 | import app.dapk.st.core.store 6 | 7 | private const val KEY_LOGGING_ENABLED = "key_logging_enabled" 8 | 9 | class LoggingStore(private val cachedPreferences: CachedPreferences) { 10 | 11 | suspend fun isEnabled() = cachedPreferences.readBoolean(KEY_LOGGING_ENABLED, defaultValue = false) 12 | 13 | suspend fun setEnabled(isEnabled: Boolean) { 14 | cachedPreferences.store(KEY_LOGGING_ENABLED, isEnabled) 15 | } 16 | 17 | } -------------------------------------------------------------------------------- /domains/store/src/main/kotlin/app/dapk/st/domain/application/message/MessageOptionsStore.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.domain.application.message 2 | 3 | import app.dapk.st.core.CachedPreferences 4 | import app.dapk.st.core.readBoolean 5 | import app.dapk.st.core.store 6 | 7 | private const val KEY_READ_RECEIPTS_DISABLED = "key_read_receipts_disabled" 8 | 9 | class MessageOptionsStore(private val cachedPreferences: CachedPreferences) { 10 | 11 | suspend fun isReadReceiptsDisabled() = cachedPreferences.readBoolean(KEY_READ_RECEIPTS_DISABLED, defaultValue = true) 12 | 13 | suspend fun setReadReceiptsDisabled(isDisabled: Boolean) { 14 | cachedPreferences.store(KEY_READ_RECEIPTS_DISABLED, isDisabled) 15 | } 16 | 17 | } -------------------------------------------------------------------------------- /domains/store/src/main/kotlin/app/dapk/st/domain/preference/CachingPreferences.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.domain.preference 2 | 3 | import app.dapk.st.core.CachedPreferences 4 | import app.dapk.st.core.Preferences 5 | 6 | class CachingPreferences(private val cache: PropertyCache, private val preferences: Preferences) : CachedPreferences { 7 | 8 | override suspend fun store(key: String, value: String) { 9 | cache.setValue(key, value) 10 | preferences.store(key, value) 11 | } 12 | 13 | override suspend fun readString(key: String): String? { 14 | return cache.getValue(key) ?: preferences.readString(key)?.also { 15 | cache.setValue(key, it) 16 | } 17 | } 18 | 19 | override suspend fun readString(key: String, defaultValue: String): String { 20 | return readString(key) ?: (defaultValue.also { cache.setValue(key, it) }) 21 | } 22 | 23 | override suspend fun remove(key: String) { 24 | preferences.remove(key) 25 | } 26 | 27 | override suspend fun clear() { 28 | preferences.clear() 29 | } 30 | } -------------------------------------------------------------------------------- /domains/store/src/main/kotlin/app/dapk/st/domain/preference/PropertyCache.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.domain.preference 2 | 3 | @Suppress("UNCHECKED_CAST") 4 | class PropertyCache { 5 | 6 | private val map = mutableMapOf() 7 | 8 | fun getValue(key: String): T? { 9 | return map[key] as? T? 10 | } 11 | 12 | fun setValue(key: String, value: Any) { 13 | map[key] = value 14 | } 15 | 16 | } -------------------------------------------------------------------------------- /domains/store/src/main/kotlin/app/dapk/st/domain/push/PushTokenRegistrarPreferences.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.domain.push 2 | 3 | import app.dapk.st.core.Preferences 4 | 5 | private const val SELECTION_KEY = "push_token_selection" 6 | 7 | class PushTokenRegistrarPreferences( 8 | private val preferences: Preferences, 9 | ) { 10 | 11 | suspend fun currentSelection() = preferences.readString(SELECTION_KEY) 12 | 13 | suspend fun store(registrar: String) { 14 | preferences.store(SELECTION_KEY, registrar) 15 | } 16 | } -------------------------------------------------------------------------------- /domains/store/src/main/sqldelight/app/dapk/db/migration/1.sqm: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS dbMutedRoom ( 2 | room_id TEXT NOT NULL, 3 | PRIMARY KEY (room_id) 4 | ); 5 | -------------------------------------------------------------------------------- /domains/store/src/main/sqldelight/app/dapk/db/model/EventLogger.sq: -------------------------------------------------------------------------------- 1 | CREATE TABLE dbEventLog ( 2 | id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 3 | tag TEXT NOT NULL, 4 | content TEXT NOT NULL, 5 | utcEpochSeconds INTEGER NOT NULL, 6 | logParent TEXT NOT NULL 7 | ); 8 | 9 | selectDays: 10 | SELECT DISTINCT date(utcEpochSeconds,'unixepoch') 11 | FROM dbEventLog; 12 | 13 | selectLatestByLog: 14 | SELECT id, tag, content, time(utcEpochSeconds,'unixepoch') 15 | FROM dbEventLog 16 | WHERE logParent = ? 17 | ORDER BY utcEpochSeconds DESC; 18 | 19 | selectLatestByLogFiltered: 20 | SELECT id, tag, content, time(utcEpochSeconds,'unixepoch') 21 | FROM dbEventLog 22 | WHERE logParent = ? AND tag = ? 23 | ORDER BY utcEpochSeconds DESC; 24 | 25 | insert: 26 | INSERT INTO dbEventLog(tag, content, utcEpochSeconds, logParent) 27 | VALUES (?, ?, strftime('%s','now'), strftime('%Y-%m-%d', 'now')); 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /domains/store/src/testFixtures/kotlin/fake/FakeLoggingStore.kt: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import app.dapk.st.domain.application.eventlog.LoggingStore 4 | import io.mockk.coEvery 5 | import io.mockk.mockk 6 | import test.delegateReturn 7 | 8 | class FakeLoggingStore { 9 | val instance = mockk() 10 | 11 | fun givenLoggingIsEnabled() = coEvery { instance.isEnabled() }.delegateReturn() 12 | } 13 | -------------------------------------------------------------------------------- /domains/store/src/testFixtures/kotlin/fake/FakeMessageOptionsStore.kt: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import app.dapk.st.domain.application.message.MessageOptionsStore 4 | import io.mockk.coEvery 5 | import io.mockk.mockk 6 | import test.delegateReturn 7 | 8 | class FakeMessageOptionsStore { 9 | val instance = mockk() 10 | 11 | fun givenReadReceiptsDisabled() = coEvery { instance.isReadReceiptsDisabled() }.delegateReturn() 12 | } -------------------------------------------------------------------------------- /domains/store/src/testFixtures/kotlin/fake/FakeStoreCleaner.kt: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import app.dapk.st.domain.StoreCleaner 4 | import io.mockk.mockk 5 | 6 | class FakeStoreCleaner : StoreCleaner by mockk() 7 | -------------------------------------------------------------------------------- /features/directory/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "st-feature-conventions" 3 | } 4 | 5 | android { 6 | namespace "app.dapk.st.directory" 7 | } 8 | 9 | dependencies { 10 | implementation project(":domains:android:compose-core") 11 | implementation project(":domains:android:imageloader") 12 | implementation "chat-engine:chat-engine" 13 | implementation 'screen-state:screen-android' 14 | implementation project(":features:messenger") 15 | implementation project(":core") 16 | implementation project(":design-library") 17 | implementation libs.compose.coil 18 | 19 | kotlinTest(it) 20 | 21 | testImplementation 'screen-state:state-test' 22 | testImplementation 'chat-engine:chat-engine-test' 23 | androidImportFixturesWorkaround(project, project(":core")) 24 | androidImportFixturesWorkaround(project, project(":domains:android:stub")) 25 | } -------------------------------------------------------------------------------- /features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.directory 2 | 3 | import android.content.Context 4 | import app.dapk.st.core.JobBag 5 | import app.dapk.st.core.ProvidableModule 6 | import app.dapk.st.directory.state.DirectoryEvent 7 | import app.dapk.st.directory.state.directoryReducer 8 | import app.dapk.st.engine.ChatEngine 9 | import app.dapk.st.imageloader.IconLoader 10 | 11 | class DirectoryModule( 12 | private val context: Context, 13 | private val chatEngine: ChatEngine, 14 | private val iconLoader: IconLoader, 15 | ) : ProvidableModule { 16 | 17 | fun directoryReducer(eventEmitter: suspend (DirectoryEvent) -> Unit) = directoryReducer(chatEngine, shortcutHandler(), JobBag(), eventEmitter) 18 | 19 | private fun shortcutHandler() = ShortcutHandler(context, iconLoader) 20 | } 21 | -------------------------------------------------------------------------------- /features/directory/src/main/kotlin/app/dapk/st/directory/state/DirectoryAction.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.directory.state 2 | 3 | import app.dapk.st.engine.DirectoryState 4 | import app.dapk.state.Action 5 | 6 | sealed interface ComponentLifecycle : Action { 7 | object OnVisible : ComponentLifecycle 8 | object OnGone : ComponentLifecycle 9 | } 10 | 11 | sealed interface DirectorySideEffect : Action { 12 | object ScrollToTop : DirectorySideEffect 13 | } 14 | 15 | sealed interface DirectoryStateChange : Action { 16 | object Empty : DirectoryStateChange 17 | data class Content(val content: DirectoryState) : DirectoryStateChange 18 | } 19 | -------------------------------------------------------------------------------- /features/directory/src/main/kotlin/app/dapk/st/directory/state/DirectoryReducer.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.directory.state 2 | 3 | import app.dapk.st.core.JobBag 4 | import app.dapk.st.directory.ShortcutHandler 5 | import app.dapk.st.engine.ChatEngine 6 | import app.dapk.state.* 7 | import kotlinx.coroutines.flow.launchIn 8 | import kotlinx.coroutines.flow.onEach 9 | 10 | private const val KEY_SYNCING_JOB = "sync" 11 | 12 | internal fun directoryReducer( 13 | chatEngine: ChatEngine, 14 | shortcutHandler: ShortcutHandler, 15 | jobBag: JobBag, 16 | eventEmitter: suspend (DirectoryEvent) -> Unit, 17 | ): ReducerFactory { 18 | return createReducer( 19 | initialState = DirectoryScreenState.EmptyLoading, 20 | 21 | multi(ComponentLifecycle::class) { action -> 22 | when (action) { 23 | ComponentLifecycle.OnVisible -> async { _ -> 24 | jobBag.replace(KEY_SYNCING_JOB, chatEngine.directory().onEach { 25 | shortcutHandler.onDirectoryUpdate(it.map { it.overview }) 26 | when (it.isEmpty()) { 27 | true -> dispatch(DirectoryStateChange.Empty) 28 | false -> dispatch(DirectoryStateChange.Content(it)) 29 | } 30 | }.launchIn(coroutineScope)) 31 | } 32 | 33 | ComponentLifecycle.OnGone -> sideEffect { jobBag.cancel(KEY_SYNCING_JOB) } 34 | } 35 | }, 36 | 37 | change(DirectoryStateChange::class) { action, _ -> 38 | when (action) { 39 | is DirectoryStateChange.Content -> DirectoryScreenState.Content(action.content) 40 | DirectoryStateChange.Empty -> DirectoryScreenState.Empty 41 | } 42 | }, 43 | 44 | sideEffect(DirectorySideEffect.ScrollToTop::class) { _, _ -> 45 | eventEmitter(DirectoryEvent.ScrollToTop) 46 | } 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /features/directory/src/main/kotlin/app/dapk/st/directory/state/DirectoryState.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.directory.state 2 | 3 | import app.dapk.st.state.State 4 | import app.dapk.st.engine.DirectoryState 5 | 6 | typealias DirectoryState = State 7 | 8 | sealed interface DirectoryScreenState { 9 | object EmptyLoading : DirectoryScreenState 10 | object Empty : DirectoryScreenState 11 | data class Content( 12 | val overviewState: DirectoryState, 13 | ) : DirectoryScreenState 14 | } 15 | 16 | sealed interface DirectoryEvent { 17 | data class OpenDownloadUrl(val url: String) : DirectoryEvent 18 | object ScrollToTop : DirectoryEvent 19 | } 20 | -------------------------------------------------------------------------------- /features/home/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "st-feature-conventions" 3 | } 4 | 5 | android { 6 | namespace "app.dapk.st.home" 7 | } 8 | 9 | dependencies { 10 | implementation "chat-engine:chat-engine" 11 | implementation 'screen-state:screen-android' 12 | implementation project(":features:directory") 13 | implementation project(":features:login") 14 | implementation project(":features:settings") 15 | implementation project(":features:profile") 16 | implementation project(":domains:android:compose-core") 17 | implementation project(':domains:store') 18 | implementation project(":core") 19 | implementation project(":design-library") 20 | implementation libs.compose.coil 21 | 22 | kotlinTest(it) 23 | 24 | testImplementation 'screen-state:state-test' 25 | testImplementation 'chat-engine:chat-engine-test' 26 | androidImportFixturesWorkaround(project, project(":core")) 27 | androidImportFixturesWorkaround(project, project(":domains:android:stub")) 28 | } -------------------------------------------------------------------------------- /features/home/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /features/home/src/main/kotlin/app/dapk/st/home/BetaVersionUpgradeUseCase.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.home 2 | 3 | import app.dapk.st.core.BuildMeta 4 | import app.dapk.st.domain.ApplicationPreferences 5 | import app.dapk.st.domain.ApplicationVersion 6 | import kotlinx.coroutines.CancellableContinuation 7 | import kotlinx.coroutines.runBlocking 8 | import kotlinx.coroutines.suspendCancellableCoroutine 9 | import kotlin.coroutines.resume 10 | 11 | class BetaVersionUpgradeUseCase( 12 | private val applicationPreferences: ApplicationPreferences, 13 | private val buildMeta: BuildMeta, 14 | ) { 15 | 16 | private var _continuation: CancellableContinuation? = null 17 | 18 | fun hasVersionChanged(): Boolean { 19 | return runBlocking { hasChangedVersion() } 20 | } 21 | 22 | private suspend fun hasChangedVersion(): Boolean { 23 | val readVersion = applicationPreferences.readVersion() 24 | val previousVersion = readVersion?.value 25 | val currentVersion = buildMeta.versionCode 26 | return when (previousVersion) { 27 | null -> false 28 | else -> currentVersion > previousVersion 29 | } 30 | } 31 | 32 | suspend fun waitUnitReady() { 33 | if (hasChangedVersion()) { 34 | suspendCancellableCoroutine { continuation -> 35 | _continuation = continuation 36 | } 37 | } 38 | } 39 | 40 | suspend fun notifyUpgraded() { 41 | applicationPreferences.setVersion(ApplicationVersion(buildMeta.versionCode)) 42 | _continuation?.resume(Unit) 43 | _continuation = null 44 | } 45 | 46 | } -------------------------------------------------------------------------------- /features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.home 2 | 3 | import app.dapk.st.core.JobBag 4 | import app.dapk.st.core.ProvidableModule 5 | import app.dapk.st.directory.DirectoryModule 6 | import app.dapk.st.domain.StoreModule 7 | import app.dapk.st.engine.ChatEngine 8 | import app.dapk.st.home.state.homeReducer 9 | import app.dapk.st.login.LoginModule 10 | import app.dapk.st.profile.ProfileModule 11 | import app.dapk.st.state.State 12 | import app.dapk.st.state.createStateViewModel 13 | import app.dapk.state.Action 14 | import app.dapk.state.DynamicReducers 15 | import app.dapk.state.combineReducers 16 | import kotlinx.coroutines.flow.Flow 17 | import kotlinx.coroutines.flow.filterIsInstance 18 | 19 | class HomeModule( 20 | private val chatEngine: ChatEngine, 21 | private val storeModule: StoreModule, 22 | val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase, 23 | private val profileModule: ProfileModule, 24 | private val loginModule: LoginModule, 25 | private val directoryModule: DirectoryModule, 26 | ) : ProvidableModule { 27 | 28 | internal fun compositeHomeState(): DynamicState { 29 | return createStateViewModel { 30 | combineReducers( 31 | listOf( 32 | homeReducerFactory(it), 33 | loginModule.loginReducer(it), 34 | profileModule.profileReducer(), 35 | directoryModule.directoryReducer(it) 36 | ) 37 | ) 38 | } 39 | } 40 | 41 | private fun homeReducerFactory(eventEmitter: suspend (Any) -> Unit) = 42 | homeReducer(chatEngine, storeModule.cacheCleaner(), betaVersionUpgradeUseCase, JobBag(), eventEmitter) 43 | } 44 | 45 | typealias DynamicState = State 46 | 47 | inline fun DynamicState.childState() = object : State { 48 | override fun dispatch(action: Action) = this@childState.dispatch(action) 49 | override val events: Flow = this@childState.events.filterIsInstance() 50 | override val current: S = this@childState.current.getState() 51 | } 52 | -------------------------------------------------------------------------------- /features/home/src/main/kotlin/app/dapk/st/home/state/HomeAction.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.home.state 2 | 3 | import app.dapk.st.engine.Me 4 | import app.dapk.st.home.state.HomeScreenState.Page 5 | import app.dapk.state.Action 6 | 7 | sealed interface HomeAction : Action { 8 | object LifecycleVisible : HomeAction 9 | object LifecycleGone : HomeAction 10 | 11 | object ScrollToTop : HomeAction 12 | object ClearCache : HomeAction 13 | object LoggedIn : HomeAction 14 | 15 | data class ChangePage(val page: Page) : HomeAction 16 | data class ChangePageSideEffect(val page: Page) : HomeAction 17 | data class UpdateInvitesCount(val invitesCount: Int) : HomeAction 18 | data class UpdateToSignedIn(val me: Me) : HomeAction 19 | data class UpdateState(val state: HomeScreenState) : HomeAction 20 | object InitialHome : HomeAction 21 | } 22 | -------------------------------------------------------------------------------- /features/home/src/main/kotlin/app/dapk/st/home/state/HomeState.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.home.state 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.filled.Menu 5 | import androidx.compose.material.icons.filled.Settings 6 | import androidx.compose.ui.graphics.vector.ImageVector 7 | import app.dapk.st.engine.Me 8 | import app.dapk.st.state.State 9 | 10 | typealias HomeState = State 11 | 12 | sealed interface HomeScreenState { 13 | 14 | object Loading : HomeScreenState 15 | object SignedOut : HomeScreenState 16 | data class SignedIn(val page: Page, val me: Me, val invites: Int) : HomeScreenState 17 | 18 | enum class Page(val icon: ImageVector) { 19 | Directory(Icons.Filled.Menu), 20 | Profile(Icons.Filled.Settings) 21 | } 22 | 23 | } 24 | 25 | sealed interface HomeEvent { 26 | object Relaunch : HomeEvent 27 | object OnShowContent : HomeEvent 28 | } 29 | 30 | -------------------------------------------------------------------------------- /features/login/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "st-feature-conventions" 3 | } 4 | 5 | android { 6 | namespace "app.dapk.st.login" 7 | } 8 | 9 | dependencies { 10 | implementation "chat-engine:chat-engine" 11 | implementation 'screen-state:screen-android' 12 | 13 | implementation project(":domains:android:compose-core") 14 | implementation project(":domains:android:push") 15 | implementation project(":domains:android:viewmodel") 16 | implementation project(":design-library") 17 | implementation project(":core") 18 | 19 | kotlinTest(it) 20 | 21 | testImplementation 'screen-state:state-test' 22 | testImplementation 'chat-engine:chat-engine-test' 23 | androidImportFixturesWorkaround(project, project(":core")) 24 | } -------------------------------------------------------------------------------- /features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.login 2 | 3 | import app.dapk.st.core.ProvidableModule 4 | import app.dapk.st.core.extensions.ErrorTracker 5 | import app.dapk.st.engine.ChatEngine 6 | import app.dapk.st.login.state.* 7 | import app.dapk.st.push.PushModule 8 | import app.dapk.st.state.createStateViewModel 9 | import app.dapk.state.ReducerFactory 10 | 11 | class LoginModule( 12 | private val chatEngine: ChatEngine, 13 | private val pushModule: PushModule, 14 | private val errorTracker: ErrorTracker, 15 | ) : ProvidableModule { 16 | 17 | fun loginState(): LoginState { 18 | return createStateViewModel { 19 | loginReducer(it) 20 | } 21 | } 22 | 23 | fun loginReducer(eventEmitter: suspend (LoginEvent) -> Unit): ReducerFactory { 24 | val loginUseCase = LoginUseCase(chatEngine, pushModule.pushTokenRegistrars(), errorTracker) 25 | return loginReducer(loginUseCase, eventEmitter) 26 | } 27 | } -------------------------------------------------------------------------------- /features/login/src/main/kotlin/app/dapk/st/login/state/LoginAction.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.login.state 2 | 3 | import app.dapk.state.Action 4 | 5 | sealed interface LoginAction : Action { 6 | 7 | sealed interface ComponentLifecycle : LoginAction { 8 | object Visible : ComponentLifecycle 9 | } 10 | 11 | data class Login(val userName: String, val password: String, val serverUrl: String?) : LoginAction 12 | 13 | data class UpdateContent(val content: LoginScreenState.Content) : LoginAction 14 | data class UpdateState(val state: LoginScreenState) : LoginAction 15 | } -------------------------------------------------------------------------------- /features/login/src/main/kotlin/app/dapk/st/login/state/LoginReducer.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.login.state 2 | 3 | import app.dapk.st.core.logP 4 | import app.dapk.st.engine.LoginRequest 5 | import app.dapk.st.engine.LoginResult 6 | import app.dapk.state.async 7 | import app.dapk.state.change 8 | import app.dapk.state.createReducer 9 | import kotlinx.coroutines.launch 10 | 11 | fun loginReducer( 12 | loginUseCase: LoginUseCase, 13 | eventEmitter: suspend (LoginEvent) -> Unit, 14 | ) = createReducer( 15 | initialState = LoginScreenState(showServerUrl = false, content = LoginScreenState.Content.Idle), 16 | 17 | change(LoginAction.ComponentLifecycle.Visible::class) { _, state -> 18 | LoginScreenState(state.showServerUrl, content = LoginScreenState.Content.Idle) 19 | }, 20 | 21 | change(LoginAction.UpdateContent::class) { action, state -> state.copy(content = action.content) }, 22 | 23 | change(LoginAction.UpdateState::class) { action, _ -> action.state }, 24 | 25 | async(LoginAction.Login::class) { action -> 26 | coroutineScope.launch { 27 | logP("login") { 28 | dispatch(LoginAction.UpdateContent(LoginScreenState.Content.Loading)) 29 | val request = LoginRequest(action.userName, action.password, action.serverUrl.takeIfNotEmpty()) 30 | 31 | when (val result = loginUseCase.login(request)) { 32 | is LoginResult.Error -> dispatch(LoginAction.UpdateContent(LoginScreenState.Content.Error(result.cause))) 33 | 34 | LoginResult.MissingWellKnown -> { 35 | eventEmitter.invoke(LoginEvent.WellKnownMissing) 36 | dispatch(LoginAction.UpdateState(LoginScreenState(showServerUrl = true, content = LoginScreenState.Content.Idle))) 37 | } 38 | 39 | is LoginResult.Success -> eventEmitter.invoke(LoginEvent.LoginComplete) 40 | } 41 | } 42 | } 43 | }, 44 | ) 45 | 46 | private fun String?.takeIfNotEmpty() = this?.takeIf { it.isNotEmpty() } 47 | -------------------------------------------------------------------------------- /features/login/src/main/kotlin/app/dapk/st/login/state/LoginState.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.login.state 2 | 3 | import app.dapk.st.state.State 4 | 5 | typealias LoginState = State 6 | 7 | data class LoginScreenState( 8 | val showServerUrl: Boolean, 9 | val content: Content, 10 | ) { 11 | 12 | sealed interface Content { 13 | object Idle : Content 14 | object Loading : Content 15 | data class Error(val cause: Throwable) : Content 16 | } 17 | } 18 | 19 | sealed interface LoginEvent { 20 | object LoginComplete : LoginEvent 21 | object WellKnownMissing : LoginEvent 22 | } 23 | -------------------------------------------------------------------------------- /features/login/src/main/kotlin/app/dapk/st/login/state/LoginUseCase.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.login.state 2 | 3 | import app.dapk.st.core.extensions.ErrorTracker 4 | import app.dapk.st.core.logP 5 | import app.dapk.st.engine.ChatEngine 6 | import app.dapk.st.engine.LoginRequest 7 | import app.dapk.st.engine.LoginResult 8 | import app.dapk.st.push.PushTokenRegistrar 9 | import kotlinx.coroutines.async 10 | import kotlinx.coroutines.awaitAll 11 | import kotlinx.coroutines.coroutineScope 12 | 13 | class LoginUseCase( 14 | private val chatEngine: ChatEngine, 15 | private val pushTokenRegistrar: PushTokenRegistrar, 16 | private val errorTracker: ErrorTracker, 17 | ) { 18 | suspend fun login(request: LoginRequest): LoginResult { 19 | return logP("login") { 20 | when (val result = chatEngine.login(request)) { 21 | is LoginResult.Success -> { 22 | coroutineScope { 23 | runCatching { 24 | listOf( 25 | async { pushTokenRegistrar.registerCurrentToken() }, 26 | async { chatEngine.preloadMe() }, 27 | ).awaitAll() 28 | } 29 | result 30 | } 31 | } 32 | 33 | is LoginResult.Error -> { 34 | errorTracker.track(result.cause) 35 | result 36 | } 37 | 38 | LoginResult.MissingWellKnown -> result 39 | } 40 | } 41 | } 42 | 43 | private suspend fun ChatEngine.preloadMe() = this.me(forceRefresh = false) 44 | } -------------------------------------------------------------------------------- /features/login/src/test/kotlin/app/dapk/st/login/state/fakes/FakeLoginUseCase.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.login.state.fakes 2 | 3 | import app.dapk.st.engine.LoginRequest 4 | import app.dapk.st.login.state.LoginUseCase 5 | import io.mockk.coEvery 6 | import io.mockk.mockk 7 | import test.delegateReturn 8 | 9 | class FakeLoginUseCase { 10 | 11 | val instance = mockk() 12 | 13 | fun given(loginRequest: LoginRequest) = coEvery { instance.login(loginRequest) }.delegateReturn() 14 | 15 | } -------------------------------------------------------------------------------- /features/messenger/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "st-feature-conventions" 3 | id "org.jetbrains.kotlin.plugin.parcelize" 4 | } 5 | 6 | android { 7 | namespace "app.dapk.st.messenger" 8 | } 9 | 10 | dependencies { 11 | implementation "chat-engine:chat-engine" 12 | implementation project(":domains:android:compose-core") 13 | implementation project(":domains:android:viewmodel") 14 | implementation project(":domains:store") 15 | implementation 'screen-state:screen-android' 16 | implementation project(":core") 17 | implementation project(":features:navigator") 18 | implementation project(":design-library") 19 | implementation libs.compose.coil 20 | 21 | kotlinTest(it) 22 | 23 | testImplementation 'screen-state:state-test' 24 | testImplementation 'chat-engine:chat-engine-test' 25 | 26 | androidImportFixturesWorkaround(project, project(":core")) 27 | androidImportFixturesWorkaround(project, project(":domains:android:viewmodel")) 28 | androidImportFixturesWorkaround(project, project(":domains:android:stub")) 29 | androidImportFixturesWorkaround(project, project(":domains:store")) 30 | } -------------------------------------------------------------------------------- /features/messenger/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /features/messenger/src/main/kotlin/app/dapk/st/messenger/CopyToClipboard.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.messenger 2 | 3 | import android.content.ClipData 4 | import android.content.ClipboardManager 5 | 6 | class CopyToClipboard(private val clipboard: ClipboardManager) { 7 | 8 | fun copy(copyable: Copyable) { 9 | 10 | clipboard.addPrimaryClipChangedListener { } 11 | 12 | when (copyable) { 13 | is Copyable.Text -> { 14 | clipboard.setPrimaryClip(ClipData.newPlainText("", copyable.value)) 15 | } 16 | } 17 | } 18 | 19 | sealed interface Copyable { 20 | data class Text(val value: String) : Copyable 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.messenger 2 | 3 | import android.content.ClipboardManager 4 | import android.content.Context 5 | import app.dapk.st.core.DeviceMeta 6 | import app.dapk.st.core.JobBag 7 | import app.dapk.st.core.ProvidableModule 8 | import app.dapk.st.state.createStateViewModel 9 | import app.dapk.st.domain.application.message.MessageOptionsStore 10 | import app.dapk.st.engine.ChatEngine 11 | import app.dapk.st.matrix.common.RoomId 12 | import app.dapk.st.messenger.state.MessengerState 13 | import app.dapk.st.messenger.state.messengerReducer 14 | 15 | class MessengerModule( 16 | private val chatEngine: ChatEngine, 17 | private val context: Context, 18 | private val messageOptionsStore: MessageOptionsStore, 19 | val deviceMeta: DeviceMeta, 20 | ) : ProvidableModule { 21 | 22 | internal fun messengerState(launchPayload: MessagerActivityPayload): MessengerState { 23 | return createStateViewModel { 24 | messengerReducer( 25 | JobBag(), 26 | chatEngine, 27 | CopyToClipboard(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager), 28 | deviceMeta, 29 | messageOptionsStore, 30 | RoomId(launchPayload.roomId), 31 | launchPayload.attachments, 32 | it 33 | ) 34 | } 35 | } 36 | 37 | internal fun decryptingFetcherFactory(roomId: RoomId) = DecryptingFetcherFactory(context, roomId, chatEngine.mediaDecrypter()) 38 | } -------------------------------------------------------------------------------- /features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryModule.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.messenger.gallery 2 | 3 | import android.content.ContentResolver 4 | import android.content.ContentUris 5 | import android.provider.MediaStore 6 | import app.dapk.st.core.CoroutineDispatchers 7 | import app.dapk.st.core.JobBag 8 | import app.dapk.st.core.ProvidableModule 9 | import app.dapk.st.state.createStateViewModel 10 | import app.dapk.st.messenger.gallery.state.ImageGalleryState 11 | import app.dapk.st.messenger.gallery.state.imageGalleryReducer 12 | 13 | class ImageGalleryModule( 14 | private val contentResolver: ContentResolver, 15 | private val dispatchers: CoroutineDispatchers, 16 | ) : ProvidableModule { 17 | 18 | fun imageGalleryState(roomName: String): ImageGalleryState = createStateViewModel { 19 | val uriAvoidance = MediaUriAvoidance( 20 | uriAppender = { uri, rowId -> ContentUris.withAppendedId(uri, rowId) }, 21 | externalContentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI 22 | ) 23 | imageGalleryReducer( 24 | roomName = roomName, 25 | FetchMediaFoldersUseCase(contentResolver, uriAvoidance, dispatchers), 26 | FetchMediaUseCase(contentResolver, uriAvoidance, dispatchers), 27 | JobBag(), 28 | ) 29 | } 30 | 31 | } -------------------------------------------------------------------------------- /features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/MediaStoreExtensions.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.messenger.gallery 2 | 3 | import android.os.Build 4 | import android.provider.MediaStore 5 | 6 | fun isNotPending() = if (Build.VERSION.SDK_INT <= 28) MediaStore.Images.Media.DATA + " NOT NULL" else MediaStore.MediaColumns.IS_PENDING + " != 1" 7 | -------------------------------------------------------------------------------- /features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/MediaUriAvoidance.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.messenger.gallery 2 | 3 | import android.net.Uri 4 | 5 | class MediaUriAvoidance( 6 | val uriAppender: (Uri, Long) -> Uri, 7 | val externalContentUri: Uri, 8 | ) -------------------------------------------------------------------------------- /features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/state/ImageGalleryActions.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.messenger.gallery.state 2 | 3 | import app.dapk.st.messenger.gallery.Folder 4 | import app.dapk.state.Action 5 | 6 | sealed interface ImageGalleryActions : Action { 7 | object Visible : ImageGalleryActions 8 | data class SelectFolder(val folder: Folder) : ImageGalleryActions 9 | } 10 | -------------------------------------------------------------------------------- /features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/state/ImageGalleryState.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.messenger.gallery.state 2 | 3 | import app.dapk.st.core.Lce 4 | import app.dapk.st.state.State 5 | import app.dapk.st.messenger.gallery.Folder 6 | import app.dapk.st.messenger.gallery.Media 7 | import app.dapk.state.Combined2 8 | import app.dapk.state.Route 9 | import app.dapk.state.page.PageContainer 10 | 11 | typealias ImageGalleryState = State, Unit>, Unit> 12 | 13 | sealed interface ImageGalleryPage { 14 | data class Folders(val content: Lce>) : ImageGalleryPage 15 | data class Files(val content: Lce>, val folder: Folder) : ImageGalleryPage 16 | 17 | object Routes { 18 | val folders = Route("Folders") 19 | val files = Route("Files") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /features/messenger/src/main/kotlin/app/dapk/st/messenger/roomsettings/RoomSettingsActivity.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.messenger.roomsettings 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.os.Bundle 7 | import android.os.Parcelable 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.material3.Surface 10 | import androidx.compose.ui.Modifier 11 | import app.dapk.st.core.DapkActivity 12 | import app.dapk.st.matrix.common.RoomId 13 | import kotlinx.parcelize.Parcelize 14 | 15 | class RoomSettingsActivity : DapkActivity() { 16 | 17 | companion object { 18 | fun newInstance(context: Context, roomId: RoomId): Intent { 19 | return Intent(context, RoomSettingsActivity::class.java).apply { 20 | putExtra("key", RoomSettingsActivityPayload(roomId.value)) 21 | } 22 | } 23 | } 24 | 25 | override fun onCreate(savedInstanceState: Bundle?) { 26 | super.onCreate(savedInstanceState) 27 | val payload = readPayload() 28 | setContent { 29 | Surface(Modifier.fillMaxSize()) { 30 | // MessengerScreen(RoomId(payload.roomId), payload.attachments, viewModel, navigator) 31 | } 32 | } 33 | } 34 | } 35 | 36 | @Parcelize 37 | data class RoomSettingsActivityPayload( 38 | val roomId: String 39 | ) : Parcelable 40 | 41 | fun Activity.readPayload(): T = intent.getParcelableExtra("key")!! -------------------------------------------------------------------------------- /features/messenger/src/main/kotlin/app/dapk/st/messenger/roomsettings/RoomSettingsScreen.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.messenger.roomsettings 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.runtime.Composable 5 | import app.dapk.st.design.components.TextRow 6 | 7 | @Composable 8 | fun RoomSettingsScreen() { 9 | 10 | 11 | Column { 12 | TextRow( 13 | "Discard session", content = "" 14 | ) 15 | } 16 | 17 | } -------------------------------------------------------------------------------- /features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerAction.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.messenger.state 2 | 3 | import app.dapk.st.design.components.BubbleModel 4 | import app.dapk.st.engine.MessengerPageState 5 | import app.dapk.st.engine.RoomEvent 6 | import app.dapk.st.navigator.MessageAttachment 7 | import app.dapk.state.Action 8 | 9 | sealed interface ScreenAction : Action { 10 | data class CopyToClipboard(val model: BubbleModel) : ScreenAction 11 | object SendMessage : ScreenAction 12 | object OpenGalleryPicker : ScreenAction 13 | object LeaveRoom : ScreenAction 14 | 15 | sealed interface Notifications : ScreenAction { 16 | object Mute : Notifications 17 | object Unmute : Notifications 18 | } 19 | 20 | sealed interface LeaveRoomConfirmation : ScreenAction { 21 | object Confirm : LeaveRoomConfirmation 22 | object Deny : LeaveRoomConfirmation 23 | } 24 | 25 | data class UpdateDialogState(val dialogState: DialogState?): ScreenAction 26 | } 27 | 28 | sealed interface ComponentLifecycle : Action { 29 | object Visible : ComponentLifecycle 30 | object Gone : ComponentLifecycle 31 | } 32 | 33 | sealed interface MessagesStateChange : Action { 34 | data class Content(val content: MessengerPageState) : MessagesStateChange 35 | data class MuteContent(val isMuted: Boolean) : MessagesStateChange 36 | } 37 | 38 | sealed interface ComposerStateChange : Action { 39 | data class SelectAttachmentToSend(val newValue: MessageAttachment) : ComposerStateChange 40 | data class TextUpdate(val newValue: String) : ComposerStateChange 41 | object Clear : ComposerStateChange 42 | 43 | sealed interface ReplyMode : ComposerStateChange { 44 | data class Enter(val replyingTo: RoomEvent) : ReplyMode 45 | object Exit : ReplyMode 46 | } 47 | 48 | sealed interface ImagePreview : ComposerStateChange { 49 | data class Show(val image: BubbleModel.Image) : ImagePreview 50 | object Hide : ImagePreview 51 | } 52 | } -------------------------------------------------------------------------------- /features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerState.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.messenger.state 2 | 3 | import app.dapk.st.core.Lce 4 | import app.dapk.st.design.components.BubbleModel 5 | import app.dapk.st.engine.MessengerPageState 6 | import app.dapk.st.engine.RoomEvent 7 | import app.dapk.st.matrix.common.RoomId 8 | import app.dapk.st.navigator.MessageAttachment 9 | import app.dapk.st.state.State 10 | import app.dapk.state.Action 11 | 12 | typealias MessengerState = State 13 | 14 | data class MessengerScreenState( 15 | val roomId: RoomId, 16 | val roomState: Lce, 17 | val composerState: ComposerState, 18 | val viewerState: ViewerState?, 19 | val dialogState: DialogState?, 20 | ) 21 | 22 | data class ViewerState( 23 | val event: BubbleModel.Image, 24 | ) 25 | 26 | sealed interface DialogState { 27 | data class PositiveNegative( 28 | val title: String, 29 | val subtitle: String, 30 | val positiveAction: Action, 31 | val negativeAction: Action, 32 | ) : DialogState 33 | } 34 | 35 | sealed interface MessengerEvent { 36 | object SelectImageAttachment : MessengerEvent 37 | data class Toast(val message: String) : MessengerEvent 38 | object OnLeftRoom : MessengerEvent 39 | } 40 | 41 | sealed interface ComposerState { 42 | 43 | val reply: RoomEvent? 44 | 45 | data class Text( 46 | val value: String, 47 | override val reply: RoomEvent?, 48 | ) : ComposerState 49 | 50 | data class Attachments( 51 | val values: List, 52 | override val reply: RoomEvent?, 53 | ) : ComposerState 54 | 55 | } 56 | -------------------------------------------------------------------------------- /features/navigator/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "st-feature-conventions" 3 | id "org.jetbrains.kotlin.plugin.parcelize" 4 | } 5 | 6 | android { 7 | namespace "app.dapk.st.navigator" 8 | } 9 | 10 | dependencies { 11 | compileOnly project(":domains:android:stub") 12 | implementation project(":core") 13 | implementation "chat-engine:chat-engine" 14 | } -------------------------------------------------------------------------------- /features/notifications/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "st-android-library-conventions" 3 | } 4 | 5 | android { 6 | namespace "app.dapk.st.notifications" 7 | } 8 | 9 | dependencies { 10 | implementation "chat-engine:chat-engine" 11 | implementation project(":domains:android:work") 12 | implementation project(':domains:android:push') 13 | implementation project(":domains:android:core") 14 | implementation project(":core") 15 | implementation project(":domains:android:imageloader") 16 | implementation project(":features:messenger") 17 | implementation project(":features:navigator") 18 | 19 | implementation libs.kotlin.serialization 20 | 21 | kotlinTest(it) 22 | testImplementation 'chat-engine:chat-engine-test' 23 | androidImportFixturesWorkaround(project, project(":core")) 24 | androidImportFixturesWorkaround(project, project(":domains:android:stub")) 25 | } -------------------------------------------------------------------------------- /features/notifications/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationStyle.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.notifications 2 | 3 | import android.app.Notification 4 | import android.graphics.drawable.Icon 5 | 6 | sealed interface AndroidNotificationStyle { 7 | 8 | fun build(builder: AndroidNotificationStyleBuilder): Notification.Style 9 | 10 | data class Inbox(val lines: List, val summary: String? = null) : AndroidNotificationStyle { 11 | override fun build(builder: AndroidNotificationStyleBuilder) = builder.build(this) 12 | } 13 | 14 | data class Messaging( 15 | val person: AndroidPerson, 16 | val title: String?, 17 | val isGroup: Boolean, 18 | val content: List, 19 | ) : AndroidNotificationStyle { 20 | 21 | override fun build(builder: AndroidNotificationStyleBuilder) = builder.build(this) 22 | 23 | data class AndroidPerson(val name: String, val key: String, val icon: Icon? = null) 24 | data class AndroidMessage(val sender: AndroidPerson, val content: String, val timestamp: Long) 25 | } 26 | 27 | } -------------------------------------------------------------------------------- /features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationStyleBuilder.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.notifications 2 | 3 | import android.annotation.SuppressLint 4 | import android.app.Notification 5 | import android.app.Notification.InboxStyle 6 | import android.app.Notification.MessagingStyle 7 | import android.app.Person 8 | 9 | @SuppressLint("NewApi") 10 | class AndroidNotificationStyleBuilder( 11 | private val personBuilderFactory: () -> Person.Builder = { Person.Builder() }, 12 | private val inboxStyleFactory: () -> InboxStyle = { InboxStyle() }, 13 | private val messagingStyleFactory: (Person) -> MessagingStyle = { MessagingStyle(it) }, 14 | ) { 15 | 16 | fun build(style: AndroidNotificationStyle): Notification.Style { 17 | return when (style) { 18 | is AndroidNotificationStyle.Inbox -> style.buildInboxStyle() 19 | is AndroidNotificationStyle.Messaging -> style.buildMessagingStyle() 20 | } 21 | } 22 | 23 | private fun AndroidNotificationStyle.Inbox.buildInboxStyle() = inboxStyleFactory().also { inboxStyle -> 24 | lines.forEach { inboxStyle.addLine(it) } 25 | inboxStyle.setSummaryText(summary) 26 | } 27 | 28 | private fun AndroidNotificationStyle.Messaging.buildMessagingStyle() = messagingStyleFactory( 29 | personBuilderFactory() 30 | .setName(person.name) 31 | .setKey(person.key) 32 | .build() 33 | ).also { style -> 34 | style.conversationTitle = title 35 | style.isGroupConversation = isGroup 36 | content.forEach { 37 | val sender = personBuilderFactory() 38 | .setName(it.sender.name) 39 | .setKey(it.sender.key) 40 | .setIcon(it.sender.icon) 41 | .build() 42 | style.addMessage(MessagingStyle.Message(it.content, it.timestamp, sender)) 43 | } 44 | } 45 | 46 | } -------------------------------------------------------------------------------- /features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationExtensions.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.notifications 2 | 3 | import android.app.Notification 4 | import android.content.LocusId 5 | import app.dapk.st.core.DeviceMeta 6 | import app.dapk.st.core.onAtLeastQ 7 | 8 | interface NotificationExtensions { 9 | fun Notification.Builder.applyLocusId(id: String) 10 | } 11 | 12 | internal class DefaultNotificationExtensions(private val deviceMeta: DeviceMeta) : NotificationExtensions { 13 | override fun Notification.Builder.applyLocusId(id: String) { 14 | deviceMeta.onAtLeastQ { setLocusId(LocusId(id)) } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationInviteRenderer.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.notifications 2 | 3 | import android.app.NotificationManager 4 | 5 | private const val INVITE_NOTIFICATION_ID = 103 6 | 7 | class NotificationInviteRenderer( 8 | private val notificationManager: NotificationManager, 9 | private val notificationFactory: NotificationFactory, 10 | private val androidNotificationBuilder: AndroidNotificationBuilder, 11 | ) { 12 | 13 | fun render(inviteNotification: app.dapk.st.engine.InviteNotification) { 14 | notificationManager.notify( 15 | inviteNotification.roomId.value, 16 | INVITE_NOTIFICATION_ID, 17 | inviteNotification.toAndroidNotification() 18 | ) 19 | } 20 | 21 | private fun app.dapk.st.engine.InviteNotification.toAndroidNotification() = androidNotificationBuilder.build( 22 | notificationFactory.createInvite(this) 23 | ) 24 | 25 | } -------------------------------------------------------------------------------- /features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationStateMapper.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.notifications 2 | 3 | class NotificationStateMapper( 4 | private val roomEventsToNotifiableMapper: RoomEventsToNotifiableMapper, 5 | private val notificationFactory: NotificationFactory, 6 | ) { 7 | 8 | suspend fun mapToNotifications(state: NotificationState): Notifications { 9 | val messageNotifications = createMessageNotifications(state) 10 | val roomNotifications = messageNotifications.filterIsInstance() 11 | val summaryNotification = maybeCreateSummary(roomNotifications) 12 | return Notifications(summaryNotification, messageNotifications) 13 | } 14 | 15 | private suspend fun createMessageNotifications(state: NotificationState) = state.allUnread.map { (roomOverview, events) -> 16 | val messageEvents = roomEventsToNotifiableMapper.map(events) 17 | when (messageEvents.isEmpty()) { 18 | true -> NotificationTypes.DismissRoom(roomOverview.roomId) 19 | false -> { 20 | notificationFactory.createMessageNotification( 21 | events = messageEvents, 22 | roomOverview = roomOverview, 23 | roomsWithNewEvents = state.roomsWithNewEvents, 24 | newRooms = state.newRooms 25 | ) 26 | } 27 | } 28 | } 29 | 30 | private fun maybeCreateSummary(roomNotifications: List) = when { 31 | roomNotifications.isNotEmpty() -> { 32 | notificationFactory.createSummary(roomNotifications) 33 | } 34 | else -> null 35 | } 36 | } 37 | 38 | data class Notifications(val summaryNotification: AndroidNotification?, val delegates: List) 39 | -------------------------------------------------------------------------------- /features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.notifications 2 | 3 | import android.app.NotificationManager 4 | import android.content.Context 5 | import app.dapk.st.core.CoroutineDispatchers 6 | import app.dapk.st.core.DeviceMeta 7 | import app.dapk.st.core.ProvidableModule 8 | import app.dapk.st.engine.ChatEngine 9 | import app.dapk.st.imageloader.IconLoader 10 | import app.dapk.st.navigator.IntentFactory 11 | import java.time.Clock 12 | 13 | class NotificationsModule( 14 | private val chatEngine: ChatEngine, 15 | private val iconLoader: IconLoader, 16 | private val context: Context, 17 | private val intentFactory: IntentFactory, 18 | private val dispatchers: CoroutineDispatchers, 19 | private val deviceMeta: DeviceMeta, 20 | ) : ProvidableModule { 21 | 22 | fun notificationsUseCase(): RenderNotificationsUseCase { 23 | val notificationManager = notificationManager() 24 | val androidNotificationBuilder = AndroidNotificationBuilder(context, deviceMeta, AndroidNotificationStyleBuilder()) 25 | val notificationFactory = NotificationFactory( 26 | context, 27 | NotificationStyleFactory(iconLoader, deviceMeta), 28 | intentFactory, 29 | iconLoader, 30 | deviceMeta, 31 | Clock.systemUTC(), 32 | ) 33 | val notificationMessageRenderer = NotificationMessageRenderer( 34 | notificationManager, 35 | NotificationStateMapper(RoomEventsToNotifiableMapper(), notificationFactory), 36 | androidNotificationBuilder, 37 | dispatchers 38 | ) 39 | return RenderNotificationsUseCase( 40 | notificationRenderer = notificationMessageRenderer, 41 | notificationChannels = NotificationChannels(notificationManager), 42 | inviteRenderer = NotificationInviteRenderer(notificationManager, notificationFactory, androidNotificationBuilder), 43 | chatEngine = chatEngine, 44 | ) 45 | } 46 | 47 | private fun notificationManager() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 48 | 49 | } 50 | -------------------------------------------------------------------------------- /features/notifications/src/main/kotlin/app/dapk/st/notifications/RenderNotificationsUseCase.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.notifications 2 | 3 | import app.dapk.st.engine.ChatEngine 4 | import app.dapk.st.engine.NotificationDiff 5 | import app.dapk.st.engine.RoomEvent 6 | import app.dapk.st.engine.RoomOverview 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.flow.launchIn 9 | import kotlinx.coroutines.flow.onEach 10 | 11 | class RenderNotificationsUseCase( 12 | private val notificationRenderer: NotificationMessageRenderer, 13 | private val inviteRenderer: NotificationInviteRenderer, 14 | private val chatEngine: ChatEngine, 15 | private val notificationChannels: NotificationChannels, 16 | ) { 17 | 18 | suspend fun listenForNotificationChanges(scope: CoroutineScope) { 19 | notificationChannels.initChannels() 20 | chatEngine.notificationsMessages() 21 | .onEach { (each, diff) -> renderUnreadChange(each, diff) } 22 | .launchIn(scope) 23 | 24 | chatEngine.notificationsInvites() 25 | .onEach { inviteRenderer.render(it) } 26 | .launchIn(scope) 27 | } 28 | 29 | private suspend fun renderUnreadChange(allUnread: Map>, diff: NotificationDiff) { 30 | notificationRenderer.render( 31 | NotificationState( 32 | allUnread = allUnread, 33 | removedRooms = diff.removed.keys, 34 | roomsWithNewEvents = diff.changedOrNew.keys, 35 | newRooms = diff.newRooms, 36 | ) 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /features/notifications/src/main/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapper.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.notifications 2 | 3 | import app.dapk.st.engine.RoomEvent 4 | import app.dapk.st.matrix.common.RoomMember 5 | import app.dapk.st.matrix.common.asString 6 | 7 | class RoomEventsToNotifiableMapper { 8 | 9 | fun map(events: List): List { 10 | return events.map { Notifiable(content = it.toNotifiableContent(), it.utcTimestamp, it.author) } 11 | } 12 | 13 | private fun RoomEvent.toNotifiableContent(): String = when (this) { 14 | is RoomEvent.Image -> "\uD83D\uDCF7" 15 | is RoomEvent.Message -> this.content.asString() 16 | is RoomEvent.Reply -> this.message.toNotifiableContent() 17 | is RoomEvent.Encrypted -> "Encrypted message" 18 | is RoomEvent.Redacted -> "Deleted message" 19 | } 20 | 21 | } 22 | 23 | data class Notifiable(val content: String, val utcTimestamp: Long, val author: RoomMember) 24 | -------------------------------------------------------------------------------- /features/notifications/src/main/res/drawable/ic_notification_small_icon.xml: -------------------------------------------------------------------------------- 1 | 3 | 7 | 11 | 15 | 19 | 23 | 24 | -------------------------------------------------------------------------------- /features/notifications/src/test/kotlin/app/dapk/st/notifications/AndroidNotificationStyleBuilderTest.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.notifications 2 | 3 | import fake.FakeInboxStyle 4 | import fake.FakeMessagingStyle 5 | import fake.FakePersonBuilder 6 | import org.amshove.kluent.shouldBeEqualTo 7 | import org.junit.Test 8 | 9 | class AndroidNotificationStyleBuilderTest { 10 | 11 | private val fakePersonBuilder = FakePersonBuilder() 12 | private val fakeInbox = FakeInboxStyle().also { it.captureInteractions() } 13 | private val fakeMessagingStyle = FakeMessagingStyle() 14 | 15 | private val styleBuilder = AndroidNotificationStyleBuilder( 16 | personBuilderFactory = { fakePersonBuilder.instance }, 17 | inboxStyleFactory = { fakeInbox.instance }, 18 | messagingStyleFactory = { 19 | fakeMessagingStyle.user = it 20 | fakeMessagingStyle.instance 21 | }, 22 | ) 23 | 24 | @Test 25 | fun `given an inbox style, when building android style, then returns framework version`() { 26 | val input = AndroidNotificationStyle.Inbox( 27 | lines = listOf("hello", "world"), 28 | summary = "a summary" 29 | ) 30 | 31 | val result = styleBuilder.build(input) 32 | 33 | result shouldBeEqualTo fakeInbox.instance 34 | fakeInbox.lines shouldBeEqualTo input.lines 35 | fakeInbox.summary shouldBeEqualTo input.summary 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /features/notifications/src/test/kotlin/fake/FakeNotificationChannels.kt: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import app.dapk.st.notifications.NotificationChannels 4 | import io.mockk.mockk 5 | import io.mockk.verify 6 | 7 | class FakeNotificationChannels { 8 | val instance = mockk() 9 | 10 | fun verifyInitiated() { 11 | verify { instance.initChannels() } 12 | } 13 | } -------------------------------------------------------------------------------- /features/notifications/src/test/kotlin/fake/FakeNotificationFactory.kt: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import app.dapk.st.notifications.NotificationState 4 | import app.dapk.st.notifications.NotificationStateMapper 5 | import io.mockk.coEvery 6 | import io.mockk.mockk 7 | import test.delegateReturn 8 | 9 | class FakeNotificationFactory { 10 | 11 | val instance = mockk() 12 | 13 | fun givenNotifications(state: NotificationState) = coEvery { instance.mapToNotifications(state) }.delegateReturn() 14 | 15 | } -------------------------------------------------------------------------------- /features/notifications/src/test/kotlin/fake/FakeNotificationInviteRenderer.kt: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import app.dapk.st.notifications.NotificationInviteRenderer 4 | import io.mockk.mockk 5 | 6 | class FakeNotificationInviteRenderer { 7 | val instance = mockk() 8 | } -------------------------------------------------------------------------------- /features/notifications/src/test/kotlin/fake/FakeNotificationMessageRenderer.kt: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import app.dapk.st.notifications.NotificationMessageRenderer 4 | import app.dapk.st.notifications.NotificationState 5 | import app.dapk.st.engine.UnreadNotifications 6 | import io.mockk.coVerify 7 | import io.mockk.mockk 8 | 9 | class FakeNotificationMessageRenderer { 10 | val instance = mockk() 11 | 12 | fun verifyRenders(vararg unreadNotifications: app.dapk.st.engine.UnreadNotifications) { 13 | unreadNotifications.forEach { unread -> 14 | coVerify { 15 | instance.render( 16 | NotificationState( 17 | allUnread = unread.first, 18 | removedRooms = unread.second.removed.keys, 19 | roomsWithNewEvents = unread.second.changedOrNew.keys, 20 | newRooms = unread.second.newRooms, 21 | ) 22 | ) 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /features/notifications/src/test/kotlin/fixture/NotificationFixtures.kt: -------------------------------------------------------------------------------- 1 | package fixture 2 | 3 | import app.dapk.st.matrix.common.RoomId 4 | import app.dapk.st.notifications.AndroidNotification 5 | import app.dapk.st.notifications.NotificationTypes 6 | import app.dapk.st.notifications.Notifications 7 | import fixture.NotificationDelegateFixtures.anAndroidNotification 8 | 9 | object NotificationFixtures { 10 | 11 | fun aNotifications( 12 | summaryNotification: AndroidNotification? = null, 13 | delegates: List = emptyList(), 14 | ) = Notifications(summaryNotification, delegates) 15 | 16 | fun aRoomNotification( 17 | notification: AndroidNotification = anAndroidNotification(), 18 | summary: String = "a summary line", 19 | messageCount: Int = 1, 20 | isAlerting: Boolean = false, 21 | summaryChannelId: String = "a-summary-channel-id", 22 | ) = NotificationTypes.Room( 23 | notification, 24 | aRoomId(), 25 | summary = summary, 26 | messageCount = messageCount, 27 | isAlerting = isAlerting, 28 | summaryChannelId = summaryChannelId 29 | ) 30 | 31 | fun aDismissRoomNotification( 32 | roomId: RoomId = aRoomId() 33 | ) = NotificationTypes.DismissRoom(roomId) 34 | 35 | } -------------------------------------------------------------------------------- /features/profile/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "st-feature-conventions" 3 | } 4 | 5 | android { 6 | namespace "app.dapk.st.profile" 7 | } 8 | 9 | dependencies { 10 | implementation "chat-engine:chat-engine" 11 | implementation project(":features:settings") 12 | implementation 'screen-state:screen-android' 13 | implementation project(":domains:android:compose-core") 14 | implementation project(":design-library") 15 | implementation project(":core") 16 | 17 | kotlinTest(it) 18 | 19 | testImplementation 'screen-state:state-test' 20 | testImplementation 'chat-engine:chat-engine-test' 21 | androidImportFixturesWorkaround(project, project(":core")) 22 | androidImportFixturesWorkaround(project, project(":domains:android:stub")) 23 | } -------------------------------------------------------------------------------- /features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.profile 2 | 3 | import app.dapk.st.core.JobBag 4 | import app.dapk.st.core.ProvidableModule 5 | import app.dapk.st.state.createStateViewModel 6 | import app.dapk.st.core.extensions.ErrorTracker 7 | import app.dapk.st.engine.ChatEngine 8 | import app.dapk.st.profile.state.ProfileState 9 | import app.dapk.st.profile.state.ProfileUseCase 10 | import app.dapk.st.profile.state.profileReducer 11 | 12 | class ProfileModule( 13 | private val chatEngine: ChatEngine, 14 | private val errorTracker: ErrorTracker, 15 | ) : ProvidableModule { 16 | 17 | fun profileState(): ProfileState { 18 | return createStateViewModel { profileReducer() } 19 | } 20 | 21 | fun profileReducer() = profileReducer(chatEngine, errorTracker, ProfileUseCase(chatEngine, errorTracker), JobBag()) 22 | 23 | } -------------------------------------------------------------------------------- /features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileAction.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.profile.state 2 | 3 | import app.dapk.st.matrix.common.RoomId 4 | import app.dapk.state.Action 5 | 6 | sealed interface ProfileAction : Action { 7 | 8 | sealed interface ComponentLifecycle : ProfileAction { 9 | object Visible : ComponentLifecycle 10 | object Gone : ComponentLifecycle 11 | } 12 | 13 | object GoToInvitations : ProfileAction 14 | data class AcceptRoomInvite(val roomId: RoomId) : ProfileAction 15 | data class RejectRoomInvite(val roomId: RoomId) : ProfileAction 16 | object Reset : ProfileAction 17 | } -------------------------------------------------------------------------------- /features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileState.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.profile.state 2 | 3 | import app.dapk.st.core.Lce 4 | import app.dapk.st.state.State 5 | import app.dapk.st.engine.Me 6 | import app.dapk.st.engine.RoomInvite 7 | import app.dapk.state.Combined2 8 | import app.dapk.state.Route 9 | import app.dapk.state.page.PageContainer 10 | 11 | typealias ProfileState = State, Unit>, Unit> 12 | 13 | sealed interface Page { 14 | data class Profile(val content: Lce) : Page { 15 | data class Content( 16 | val me: Me, 17 | val invitationsCount: Int, 18 | ) 19 | } 20 | 21 | data class Invitations(val content: Lce>) : Page 22 | 23 | object Routes { 24 | val profile = Route("Profile") 25 | val invitation = Route("Invitations") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /features/profile/src/main/kotlin/app/dapk/st/profile/state/ProfileUseCase.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.profile.state 2 | 3 | import app.dapk.st.core.Lce 4 | import app.dapk.st.core.extensions.ErrorTracker 5 | import app.dapk.st.engine.ChatEngine 6 | import app.dapk.st.engine.Me 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.combine 9 | import kotlinx.coroutines.flow.flow 10 | import kotlinx.coroutines.flow.map 11 | 12 | class ProfileUseCase( 13 | private val chatEngine: ChatEngine, 14 | private val errorTracker: ErrorTracker, 15 | ) { 16 | 17 | private var meCache: Me? = null 18 | 19 | fun content(): Flow> { 20 | return combine(fetchMe(), chatEngine.invites(), transform = { me, invites -> me to invites }).map { (me, invites) -> 21 | when (me.isSuccess) { 22 | true -> Lce.Content(Page.Profile.Content(me.getOrThrow(), invites.size)) 23 | false -> Lce.Error(me.exceptionOrNull()!!) 24 | } 25 | } 26 | } 27 | 28 | private fun fetchMe() = flow { 29 | meCache?.let { emit(Result.success(it)) } 30 | val result = runCatching { chatEngine.me(forceRefresh = true) } 31 | .onFailure { errorTracker.track(it, "Loading profile") } 32 | .onSuccess { meCache = it } 33 | emit(result) 34 | } 35 | } -------------------------------------------------------------------------------- /features/profile/src/test/kotlin/app/dapk/st/profile/state/ProfileUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.profile.state 2 | 3 | import app.dapk.st.core.Lce 4 | import app.dapk.st.engine.Me 5 | import app.dapk.st.matrix.common.HomeServerUrl 6 | import fake.FakeChatEngine 7 | import fake.FakeErrorTracker 8 | import fixture.aRoomInvite 9 | import fixture.aUserId 10 | import kotlinx.coroutines.flow.first 11 | import kotlinx.coroutines.test.runTest 12 | import org.amshove.kluent.shouldBeEqualTo 13 | import org.junit.Test 14 | 15 | private val A_ME = Me(aUserId(), null, null, HomeServerUrl("ignored")) 16 | private val AN_INVITES_LIST = listOf(aRoomInvite(), aRoomInvite(), aRoomInvite(), aRoomInvite()) 17 | private val AN_ERROR = RuntimeException() 18 | 19 | class ProfileUseCaseTest { 20 | 21 | private val fakeChatEngine = FakeChatEngine() 22 | private val fakeErrorTracker = FakeErrorTracker() 23 | 24 | private val useCase = ProfileUseCase(fakeChatEngine, fakeErrorTracker) 25 | 26 | @Test 27 | fun `given me and invites, when fetching content, then emits content`() = runTest { 28 | fakeChatEngine.givenMe(forceRefresh = true).returns(A_ME) 29 | fakeChatEngine.givenInvites().emits(AN_INVITES_LIST) 30 | 31 | val result = useCase.content().first() 32 | 33 | result shouldBeEqualTo Lce.Content(Page.Profile.Content(A_ME, invitationsCount = AN_INVITES_LIST.size)) 34 | } 35 | 36 | @Test 37 | fun `given me fails, when fetching content, then emits error`() = runTest { 38 | fakeChatEngine.givenMe(forceRefresh = true).throws(AN_ERROR) 39 | fakeChatEngine.givenInvites().emits(emptyList()) 40 | 41 | val result = useCase.content().first() 42 | 43 | result shouldBeEqualTo Lce.Error(AN_ERROR) 44 | } 45 | } -------------------------------------------------------------------------------- /features/settings/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "st-feature-conventions" 3 | } 4 | 5 | dependencies { 6 | implementation "chat-engine:chat-engine" 7 | implementation project(":features:navigator") 8 | implementation project(':domains:store') 9 | implementation project(':domains:android:push') 10 | implementation project(":domains:android:compose-core") 11 | implementation project(":domains:android:viewmodel") 12 | implementation 'screen-state:screen-android' 13 | implementation project(":design-library") 14 | implementation project(":core") 15 | 16 | kotlinTest(it) 17 | 18 | testImplementation 'screen-state:state-test' 19 | testImplementation 'chat-engine:chat-engine-test' 20 | androidImportFixturesWorkaround(project, project(":core")) 21 | androidImportFixturesWorkaround(project, project(":domains:android:viewmodel")) 22 | androidImportFixturesWorkaround(project, project(":domains:android:stub")) 23 | androidImportFixturesWorkaround(project, project(":domains:store")) 24 | } -------------------------------------------------------------------------------- /features/settings/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /features/settings/src/main/kotlin/app/dapk/st/settings/SettingsActivity.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.settings 2 | 3 | import android.os.Bundle 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.material3.Surface 6 | import androidx.compose.ui.Modifier 7 | import app.dapk.st.core.DapkActivity 8 | import app.dapk.st.core.module 9 | import app.dapk.st.core.resetModules 10 | import app.dapk.st.settings.state.SettingsState 11 | import app.dapk.st.state.state 12 | 13 | class SettingsActivity : DapkActivity() { 14 | 15 | private val settingsState: SettingsState by state { module().settingsState() } 16 | 17 | override fun onCreate(savedInstanceState: Bundle?) { 18 | super.onCreate(savedInstanceState) 19 | setContent { 20 | Surface(Modifier.fillMaxSize()) { 21 | SettingsScreen(settingsState, onSignOut = { 22 | resetModules() 23 | navigator.navigate.toHome() 24 | finish() 25 | }, navigator) 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /features/settings/src/main/kotlin/app/dapk/st/settings/SettingsModule.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.settings 2 | 3 | import android.content.ContentResolver 4 | import app.dapk.st.core.* 5 | import app.dapk.st.domain.StoreModule 6 | import app.dapk.st.domain.application.eventlog.LoggingStore 7 | import app.dapk.st.domain.application.message.MessageOptionsStore 8 | import app.dapk.st.engine.ChatEngine 9 | import app.dapk.st.push.PushModule 10 | import app.dapk.st.settings.eventlogger.EventLoggerViewModel 11 | import app.dapk.st.settings.state.SettingsState 12 | import app.dapk.st.settings.state.settingsReducer 13 | import app.dapk.st.state.createStateViewModel 14 | 15 | class SettingsModule( 16 | private val chatEngine: ChatEngine, 17 | private val storeModule: StoreModule, 18 | private val pushModule: PushModule, 19 | private val contentResolver: ContentResolver, 20 | private val buildMeta: BuildMeta, 21 | private val deviceMeta: DeviceMeta, 22 | private val coroutineDispatchers: CoroutineDispatchers, 23 | private val themeStore: ThemeStore, 24 | private val loggingStore: LoggingStore, 25 | private val messageOptionsStore: MessageOptionsStore, 26 | ) : ProvidableModule { 27 | 28 | internal fun settingsState(): SettingsState { 29 | return createStateViewModel { 30 | settingsReducer( 31 | chatEngine, 32 | storeModule.cacheCleaner(), 33 | contentResolver, 34 | UriFilenameResolver(contentResolver, coroutineDispatchers), 35 | SettingsItemFactory(buildMeta, deviceMeta, pushModule.pushTokenRegistrars(), themeStore, loggingStore, messageOptionsStore), 36 | pushModule.pushTokenRegistrars(), 37 | themeStore, 38 | loggingStore, 39 | messageOptionsStore, 40 | it, 41 | JobBag(), 42 | ) 43 | } 44 | } 45 | 46 | 47 | internal fun eventLogViewModel(): EventLoggerViewModel { 48 | return EventLoggerViewModel(storeModule.eventLogStore()) 49 | } 50 | } -------------------------------------------------------------------------------- /features/settings/src/main/kotlin/app/dapk/st/settings/UriFilenameResolver.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.settings 2 | 3 | import android.content.ContentResolver 4 | import android.net.Uri 5 | import android.provider.OpenableColumns 6 | import app.dapk.st.core.CoroutineDispatchers 7 | import app.dapk.st.core.withIoContext 8 | 9 | class UriFilenameResolver( 10 | private val contentResolver: ContentResolver, 11 | private val coroutineDispatchers: CoroutineDispatchers 12 | ) { 13 | 14 | suspend fun readFilenameFromUri(uri: Uri): String { 15 | val fallback = uri.path?.substringAfterLast('/') ?: throw IllegalStateException("expecting a file uri but got $uri") 16 | return when (uri.scheme) { 17 | "content" -> readResolvedDisplayName(uri) ?: fallback 18 | else -> fallback 19 | } 20 | } 21 | 22 | private suspend fun readResolvedDisplayName(uri: Uri): String? { 23 | return coroutineDispatchers.withIoContext { 24 | contentResolver.query(uri, null, null, null, null)?.use { cursor -> 25 | when { 26 | cursor.moveToFirst() -> { 27 | cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) 28 | .takeIf { it != -1 } 29 | ?.let { cursor.getString(it) } 30 | } 31 | else -> null 32 | } 33 | } 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLogActivity.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.settings.eventlogger 2 | 3 | import android.os.Bundle 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.material3.Surface 6 | import androidx.compose.ui.Modifier 7 | import app.dapk.st.core.DapkActivity 8 | import app.dapk.st.core.module 9 | import app.dapk.st.core.viewModel 10 | import app.dapk.st.settings.SettingsModule 11 | 12 | class EventLogActivity : DapkActivity() { 13 | 14 | private val viewModel by viewModel { module().eventLogViewModel() } 15 | 16 | override fun onCreate(savedInstanceState: Bundle?) { 17 | super.onCreate(savedInstanceState) 18 | setContent { 19 | Surface(Modifier.fillMaxSize()) { 20 | EventLogScreen(viewModel) 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLoggerState.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.settings.eventlogger 2 | 3 | import app.dapk.st.core.Lce 4 | import app.dapk.st.domain.application.eventlog.LogLine 5 | 6 | data class EventLoggerState( 7 | val logs: Lce>, 8 | val selectedState: SelectedState?, 9 | ) 10 | 11 | data class SelectedState( 12 | val selectedPage: String, 13 | val content: Lce>, 14 | val filter: String?, 15 | ) -------------------------------------------------------------------------------- /features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLoggerViewModel.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.settings.eventlogger 2 | 3 | import androidx.lifecycle.viewModelScope 4 | import app.dapk.st.core.Lce 5 | import app.dapk.st.domain.application.eventlog.EventLogPersistence 6 | import app.dapk.st.viewmodel.DapkViewModel 7 | import kotlinx.coroutines.Job 8 | import kotlinx.coroutines.flow.collect 9 | import kotlinx.coroutines.flow.onEach 10 | import kotlinx.coroutines.launch 11 | 12 | class EventLoggerViewModel( 13 | private val persistence: EventLogPersistence 14 | ) : DapkViewModel( 15 | initialState = EventLoggerState( 16 | logs = Lce.Loading(), 17 | selectedState = null, 18 | ) 19 | ) { 20 | 21 | private var logObserverJob: Job? = null 22 | 23 | fun start() { 24 | viewModelScope.launch { 25 | updateState { copy(logs = Lce.Loading()) } 26 | val days = persistence.days() 27 | updateState { copy(logs = Lce.Content(days)) } 28 | } 29 | } 30 | 31 | fun selectLog(logKey: String, filter: String?) { 32 | logObserverJob?.cancel() 33 | updateState { copy(selectedState = SelectedState(selectedPage = logKey, content = Lce.Loading(), filter = filter)) } 34 | 35 | logObserverJob = viewModelScope.launch { 36 | persistence.latest(logKey, filter) 37 | .onEach { 38 | updateState { copy(selectedState = selectedState?.copy(content = Lce.Content(it))) } 39 | }.collect() 40 | } 41 | } 42 | 43 | override fun onCleared() { 44 | logObserverJob?.cancel() 45 | } 46 | 47 | fun exitLog() { 48 | logObserverJob?.cancel() 49 | updateState { copy(selectedState = null) } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /features/settings/src/main/kotlin/app/dapk/st/settings/state/SettingsAction.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.settings.state 2 | 3 | import android.net.Uri 4 | import app.dapk.st.push.Registrar 5 | import app.dapk.st.settings.SettingItem 6 | import app.dapk.state.Action 7 | 8 | internal sealed interface ScreenAction : Action { 9 | data class OnClick(val item: SettingItem) : ScreenAction 10 | object OpenImportRoom : ScreenAction 11 | } 12 | 13 | internal sealed interface RootActions : Action { 14 | object FetchProviders : RootActions 15 | data class SelectPushProvider(val registrar: Registrar) : RootActions 16 | data class ImportKeysFromFile(val file: Uri, val passphrase: String) : RootActions 17 | data class SelectKeysFile(val file: Uri) : RootActions 18 | } 19 | 20 | internal sealed interface ComponentLifecycle : Action { 21 | object Visible : ComponentLifecycle 22 | } 23 | -------------------------------------------------------------------------------- /features/settings/src/test/kotlin/app/dapk/st/settings/FakeThemeStore.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.settings 2 | 3 | import app.dapk.st.core.ThemeStore 4 | import io.mockk.coEvery 5 | import io.mockk.every 6 | import io.mockk.mockk 7 | import test.delegateReturn 8 | 9 | class FakeThemeStore { 10 | val instance = mockk() 11 | 12 | fun givenMaterialYouIsEnabled() = coEvery { instance.isMaterialYouEnabled() }.delegateReturn() 13 | } -------------------------------------------------------------------------------- /features/settings/src/test/kotlin/internalfake/FakeSettingsItemFactory.kt: -------------------------------------------------------------------------------- 1 | package internalfake 2 | 3 | import app.dapk.st.settings.SettingsItemFactory 4 | import io.mockk.coEvery 5 | import io.mockk.every 6 | import io.mockk.mockk 7 | import test.delegateReturn 8 | 9 | internal class FakeSettingsItemFactory { 10 | val instance = mockk() 11 | 12 | fun givenRoot() = coEvery { instance.root() }.delegateReturn() 13 | } -------------------------------------------------------------------------------- /features/settings/src/test/kotlin/internalfake/FakeUriFilenameResolver.kt: -------------------------------------------------------------------------------- 1 | package internalfake 2 | 3 | import android.net.Uri 4 | import app.dapk.st.settings.UriFilenameResolver 5 | import io.mockk.coEvery 6 | import io.mockk.mockk 7 | 8 | class FakeUriFilenameResolver { 9 | val instance = mockk() 10 | 11 | fun givenFilename(uri: Uri) = coEvery { instance.readFilenameFromUri(uri) } 12 | } -------------------------------------------------------------------------------- /features/settings/src/test/kotlin/internalfixture/PageFixture.kt: -------------------------------------------------------------------------------- 1 | package internalfixture 2 | 3 | import app.dapk.st.settings.Page 4 | import app.dapk.state.SpiderPage 5 | 6 | internal fun aImportRoomKeysPage( 7 | state: Page.ImportRoomKey = Page.ImportRoomKey() 8 | ) = SpiderPage( 9 | route = Page.Routes.importRoomKeys, 10 | label = "Import room keys", 11 | parent = Page.Routes.encryption, 12 | state = state 13 | ) 14 | 15 | internal fun aPushProvidersPage( 16 | state: Page.PushProviders = Page.PushProviders() 17 | ) = SpiderPage( 18 | route = Page.Routes.pushProviders, 19 | label = "Push providers", 20 | parent = Page.Routes.root, 21 | state = state 22 | ) 23 | -------------------------------------------------------------------------------- /features/settings/src/test/kotlin/internalfixture/SettingItemFixture.kt: -------------------------------------------------------------------------------- 1 | package internalfixture 2 | 3 | import app.dapk.st.settings.SettingItem 4 | 5 | internal fun aSettingTextItem( 6 | id: SettingItem.Id = SettingItem.Id.Ignored, 7 | content: String = "text-content", 8 | subtitle: String? = null, 9 | enabled: Boolean = true, 10 | ) = SettingItem.Text(id, content, subtitle, enabled) 11 | 12 | internal fun aSettingHeaderItem( 13 | label: String = "header-label", 14 | ) = SettingItem.Header(label) -------------------------------------------------------------------------------- /features/share-entry/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "st-feature-conventions" 3 | } 4 | 5 | dependencies { 6 | implementation "chat-engine:chat-engine" 7 | implementation project(":domains:android:compose-core") 8 | implementation project(":domains:android:viewmodel") 9 | // implementation project(':domains:store') 10 | implementation project(":core") 11 | implementation project(":design-library") 12 | implementation project(":features:navigator") 13 | } -------------------------------------------------------------------------------- /features/share-entry/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /features/share-entry/src/main/kotlin/app/dapk/st/share/FetchRoomsUseCase.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.share 2 | 3 | import app.dapk.st.engine.ChatEngine 4 | import kotlinx.coroutines.flow.first 5 | 6 | class FetchRoomsUseCase( 7 | private val chatEngine: ChatEngine, 8 | ) { 9 | 10 | suspend fun fetch(): List { 11 | return chatEngine.directory().first().map { 12 | val overview = it.overview 13 | Item( 14 | overview.roomId, 15 | overview.roomAvatarUrl, 16 | overview.roomName ?: "", 17 | chatEngine.findMembersSummary(overview.roomId).map { it.displayName ?: it.id.value } 18 | ) 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryActivity.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.share 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import android.os.Bundle 6 | import android.os.Parcelable 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.material3.Surface 9 | import androidx.compose.ui.Modifier 10 | import app.dapk.st.core.DapkActivity 11 | import app.dapk.st.core.module 12 | import app.dapk.st.core.viewModel 13 | 14 | class ShareEntryActivity : DapkActivity() { 15 | 16 | private val viewModel by viewModel { module().shareEntryViewModel() } 17 | 18 | override fun onCreate(savedInstanceState: Bundle?) { 19 | super.onCreate(savedInstanceState) 20 | val urisToShare = intent.readSendUrisOrNull() ?: throw IllegalArgumentException("Expected deeplink uris but they were missing") 21 | setContent { 22 | Surface(Modifier.fillMaxSize()) { 23 | ShareEntryScreen(navigator, viewModel) 24 | } 25 | } 26 | viewModel.withUris(urisToShare) 27 | } 28 | } 29 | 30 | private fun Intent.readSendUrisOrNull(): List? { 31 | return when (this.action) { 32 | Intent.ACTION_SEND -> { 33 | if (this.hasExtra(Intent.EXTRA_STREAM)) { 34 | listOf(this.getParcelableExtra(Intent.EXTRA_STREAM) as Uri) 35 | } else { 36 | null 37 | } 38 | } 39 | Intent.ACTION_SEND_MULTIPLE -> { 40 | if (this.hasExtra(Intent.EXTRA_STREAM)) { 41 | (this.getParcelableArrayExtra(Intent.EXTRA_STREAM) as Array).toList() 42 | } else { 43 | null 44 | } 45 | } 46 | else -> null 47 | } 48 | } -------------------------------------------------------------------------------- /features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryModule.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.share 2 | 3 | import app.dapk.st.core.ProvidableModule 4 | import app.dapk.st.engine.ChatEngine 5 | 6 | class ShareEntryModule( 7 | private val chatEngine: ChatEngine, 8 | ) : ProvidableModule { 9 | 10 | fun shareEntryViewModel(): ShareEntryViewModel { 11 | return ShareEntryViewModel(FetchRoomsUseCase(chatEngine)) 12 | } 13 | } -------------------------------------------------------------------------------- /features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryState.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.share 2 | 3 | import app.dapk.st.core.AndroidUri 4 | import app.dapk.st.matrix.common.AvatarUrl 5 | import app.dapk.st.matrix.common.RoomId 6 | 7 | sealed interface DirectoryScreenState { 8 | 9 | object EmptyLoading : DirectoryScreenState 10 | object Empty : DirectoryScreenState 11 | data class Content( 12 | val items: List, 13 | ) : DirectoryScreenState 14 | } 15 | 16 | sealed interface DirectoryEvent { 17 | data class SelectRoom(val item: Item, val uris: List) : DirectoryEvent 18 | } 19 | 20 | data class Item(val id: RoomId, val roomAvatarUrl: AvatarUrl?, val roomName: String, val members: List) 21 | -------------------------------------------------------------------------------- /features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryViewModel.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.share 2 | 3 | import android.net.Uri 4 | import androidx.lifecycle.viewModelScope 5 | import app.dapk.st.core.AndroidUri 6 | import app.dapk.st.viewmodel.DapkViewModel 7 | import app.dapk.st.viewmodel.MutableStateFactory 8 | import app.dapk.st.viewmodel.defaultStateFactory 9 | import kotlinx.coroutines.Job 10 | import kotlinx.coroutines.launch 11 | 12 | class ShareEntryViewModel( 13 | private val fetchRoomsUseCase: FetchRoomsUseCase, 14 | factory: MutableStateFactory = defaultStateFactory(), 15 | ) : DapkViewModel( 16 | initialState = DirectoryScreenState.EmptyLoading, 17 | factory, 18 | ) { 19 | 20 | private var urisToShare: List? = null 21 | private var syncJob: Job? = null 22 | 23 | fun start() { 24 | syncJob = viewModelScope.launch { 25 | state = DirectoryScreenState.Content(fetchRoomsUseCase.fetch()) 26 | } 27 | } 28 | 29 | fun stop() { 30 | syncJob?.cancel() 31 | } 32 | 33 | fun withUris(urisToShare: List) { 34 | this.urisToShare = urisToShare.map { AndroidUri(it.toString()) } 35 | } 36 | 37 | fun onRoomSelected(item: Item) { 38 | viewModelScope.launch { 39 | _events.emit(DirectoryEvent.SelectRoom(item, uris = urisToShare ?: throw IllegalArgumentException("Not uris set"))) 40 | } 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /features/verification/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "st-feature-conventions" 3 | } 4 | 5 | dependencies { 6 | implementation "chat-engine:chat-engine" 7 | implementation project(":domains:android:compose-core") 8 | implementation project(":domains:android:viewmodel") 9 | implementation project(":design-library") 10 | implementation project(":core") 11 | } -------------------------------------------------------------------------------- /features/verification/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /features/verification/src/main/kotlin/app/dapk/st/verification/VerificationActivity.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.verification 2 | 3 | import android.os.Bundle 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.material3.Surface 6 | import androidx.compose.ui.Modifier 7 | import app.dapk.st.core.DapkActivity 8 | import app.dapk.st.core.module 9 | import app.dapk.st.core.viewModel 10 | 11 | class VerificationActivity : DapkActivity() { 12 | 13 | private val verificationViewModel by viewModel { module().verificationViewModel() } 14 | 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | super.onCreate(savedInstanceState) 17 | setContent { 18 | Surface(Modifier.fillMaxSize()) { 19 | VerificationScreen(verificationViewModel) 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /features/verification/src/main/kotlin/app/dapk/st/verification/VerificationModule.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.verification 2 | 3 | import app.dapk.st.core.ProvidableModule 4 | import app.dapk.st.engine.ChatEngine 5 | 6 | class VerificationModule( 7 | private val chatEngine: ChatEngine, 8 | ) : ProvidableModule { 9 | 10 | fun verificationViewModel(): VerificationViewModel { 11 | return VerificationViewModel(chatEngine) 12 | } 13 | 14 | } -------------------------------------------------------------------------------- /features/verification/src/main/kotlin/app/dapk/st/verification/VerificationScreen.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.verification 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.material3.Button 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.Composable 8 | 9 | @Composable 10 | fun VerificationScreen(viewModel: VerificationViewModel) { 11 | Column { 12 | Text("Verification request") 13 | 14 | 15 | Row { 16 | Button(onClick = { 17 | viewModel.inSecureAccept() 18 | }) { 19 | Text("Yes".uppercase()) 20 | } 21 | } 22 | 23 | 24 | } 25 | 26 | 27 | } -------------------------------------------------------------------------------- /features/verification/src/main/kotlin/app/dapk/st/verification/VerificationState.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.verification 2 | 3 | data class VerificationScreenState(val foo: String) 4 | 5 | sealed interface VerificationEvent { 6 | 7 | } 8 | 9 | -------------------------------------------------------------------------------- /features/verification/src/main/kotlin/app/dapk/st/verification/VerificationViewModel.kt: -------------------------------------------------------------------------------- 1 | package app.dapk.st.verification 2 | 3 | import app.dapk.st.engine.ChatEngine 4 | import app.dapk.st.viewmodel.DapkViewModel 5 | 6 | class VerificationViewModel( 7 | private val chatEngine: ChatEngine, 8 | ) : DapkViewModel( 9 | initialState = VerificationScreenState(foo = "") 10 | ) { 11 | fun inSecureAccept() { 12 | // TODO verify via chat-engine 13 | // viewModelScope.launch { 14 | // cryptoService.verificationAction(Verification.Action.InsecureAccept) 15 | // } 16 | } 17 | 18 | 19 | } 20 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | 3 | org.gradle.jvmargs=-Xmx6144M -Xms2048M -Dfile.encoding=UTF-8 -XX:+UseParallelGC 4 | org.gradle.daemon=true 5 | org.gradle.parallel=true 6 | org.gradle.unsafe.configuration-cache=true 7 | org.gradle.unsafe.configuration-cache-problems=warn 8 | org.gradle.caching=true 9 | org.gradle.configureondemand=true 10 | org.gradle.vfs.watch=true 11 | 12 | android.useAndroidX=true 13 | android.enableJetifier=false 14 | android.debug.obsoleteApi=false 15 | android.injected.build.model.only.versioned=3 16 | android.enableResourceOptimizations=true 17 | android.enableR8.fullMode=true 18 | android.nonTransitiveRClass=true 19 | android.disableAutomaticComponentCreation=true 20 | #android.experimental.enableNewResourceShrinker.preciseShrinking=true 21 | 22 | kapt.incremental.apt=true 23 | kapt.use.worker.api=true 24 | kapt.include.compile.classpath=false 25 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ouchadam/small-talk/5a391676d0d482596fe5d270798c2b361dba67e7/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | includeBuild 'tools/conventions' 3 | repositories { 4 | gradlePluginPortal() 5 | google() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 10 | apply from: "gradle/repositories.gradle" 11 | applyRepositories(it) 12 | } 13 | 14 | rootProject.name = "SmallTalk" 15 | 16 | includeBuild 'screen-state' 17 | includeBuild 'chat-engine' 18 | 19 | include ':app' 20 | 21 | include ':design-library' 22 | 23 | include ':features:directory' 24 | include ':features:login' 25 | include ':features:home' 26 | include ':features:settings' 27 | include ':features:profile' 28 | include ':features:notifications' 29 | include ':features:messenger' 30 | include ':features:navigator' 31 | include ':features:verification' 32 | include ':features:share-entry' 33 | 34 | include ':domains:android:stub' 35 | include ':domains:android:core' 36 | include ':domains:android:compose-core' 37 | include ':domains:android:imageloader' 38 | include ':domains:android:work' 39 | include ':domains:android:tracking' 40 | include ':domains:android:push' 41 | include ':domains:android:viewmodel-stub' 42 | include ':domains:android:viewmodel' 43 | include ':domains:store' 44 | 45 | include ':domains:firebase:crashlytics' 46 | include ':domains:firebase:crashlytics-noop' 47 | include ':domains:firebase:messaging' 48 | include ':domains:firebase:messaging-noop' 49 | 50 | include ':core' -------------------------------------------------------------------------------- /tools/benchmark/benchmark.profile: -------------------------------------------------------------------------------- 1 | clean_assemble { 2 | tasks = ["clean", ":app:assembleDebug"] 3 | } 4 | 5 | clean_assemble_no_cache { 6 | tasks = ["clean", ":app:assembleDebug"] 7 | gradle-args = ["--no-build-cache", "--no-configuration-cache"] 8 | } 9 | -------------------------------------------------------------------------------- /tools/benchmark/run_benchmark.sh: -------------------------------------------------------------------------------- 1 | if ! command -v gradle-profiler &> /dev/null 2 | then 3 | echo "gradle-profiler could not be found https://github.com/gradle/gradle-profiler" 4 | exit 5 | fi 6 | 7 | gradle-profiler \ 8 | --benchmark \ 9 | --project-dir . \ 10 | --scenario-file tools/benchmark/benchmark.profile \ 11 | --output-dir benchmark-out/output \ 12 | --gradle-user-home benchmark-out/gradle-home \ 13 | --warmups 3 \ 14 | --iterations 3 \ 15 | $1 -------------------------------------------------------------------------------- /tools/beta-release/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "beta-release", 3 | "version": "1.0.0", 4 | "main": "app.js", 5 | "license": "MIT", 6 | "type": "module", 7 | "private": true, 8 | "dependencies": { 9 | "@googleapis/androidpublisher": "^5.0.0", 10 | "matrix-js-sdk": "^25.0.0", 11 | "request": "^2.88.2" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tools/check-size.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | rm -f build/bundle-tmp/app.apks 4 | 5 | ./gradlew bundleRelease --no-configuration-cache -x uploadCrashlyticsMappingFileRelease 6 | 7 | bundletool build-apks \ 8 | --device-spec=tools/device-spec.json \ 9 | --bundle=app/build/outputs/bundle/release/app-release.aab --output=build/bundle-tmp/app.apks 10 | 11 | bundletool get-size total \ 12 | --apks=build/bundle-tmp/app.apks \ 13 | --human-readable-sizes -------------------------------------------------------------------------------- /tools/debug.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ouchadam/small-talk/5a391676d0d482596fe5d270798c2b361dba67e7/tools/debug.keystore -------------------------------------------------------------------------------- /tools/generate-fdroid-release.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | set -e 3 | 4 | WORKING_DIR=app/build/outputs/apk/release 5 | UNSIGNED=$WORKING_DIR/app-foss-release-unsigned.apk 6 | ALIGNED_UNSIGNED=$WORKING_DIR/app-foss-release-unsigned-aligned.apk 7 | SIGNED=$WORKING_DIR/app-foss-release-signed.apk 8 | 9 | ZIPALIGN=$(find "$ANDROID_HOME" -iname zipalign -print -quit) 10 | APKSIGNER=$(find "$ANDROID_HOME" -iname apksigner -print -quit) 11 | 12 | ./gradlew assembleRelease -Pfoss -Punsigned --no-daemon --no-configuration-cache --no-build-cache 13 | 14 | $ZIPALIGN -v -p 4 $UNSIGNED $ALIGNED_UNSIGNED 15 | 16 | $APKSIGNER sign \ 17 | --ks .secrets/fdroid.keystore \ 18 | --ks-key-alias key0 \ 19 | --ks-pass pass:$1 \ 20 | --out $SIGNED \ 21 | $ALIGNED_UNSIGNED 22 | -------------------------------------------------------------------------------- /tools/generate-release.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | ./gradlew bundleRelease -Punsigned --no-daemon --no-configuration-cache --no-build-cache 4 | 5 | WORKING_DIR=app/build/outputs/bundle/release 6 | RELEASE_AAB=$WORKING_DIR/app-release.aab 7 | 8 | cp $RELEASE_AAB $WORKING_DIR/app-release-unsigned.aab 9 | 10 | echo "signing $RELEASE_AAB" 11 | jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \ 12 | -keystore .secrets/upload-key.jks \ 13 | -storepass $1 \ 14 | $RELEASE_AAB \ 15 | key0 16 | -------------------------------------------------------------------------------- /version.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": 28, 3 | "name": "22/11/07.1" 4 | } --------------------------------------------------------------------------------