├── .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 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
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 | }
--------------------------------------------------------------------------------