Merge pull request #169 from ouchadam/release-candidate
[Auto] Release Candidate
This commit is contained in:
commit
0a6c4df6c4
|
@ -34,11 +34,14 @@ jobs:
|
|||
touch .secrets/service-account.json
|
||||
touch .secrets/matrix.json
|
||||
echo -n '${{ secrets.UPLOAD_KEY }}' | base64 --decode >> .secrets/upload-key.jks
|
||||
echo -n '${{ secrets.FDROID_KEY }}' | base64 --decode >> .secrets/fdroid.keystore
|
||||
echo -n '${{ secrets.SERVICE_ACCOUNT }}' | base64 --decode >> .secrets/service-account.json
|
||||
echo -n '${{ secrets.MATRIX }}' | base64 --decode >> .secrets/matrix.json
|
||||
|
||||
- name: Assemble release variant
|
||||
run: ./tools/generate-release.sh ${{ secrets.STORE_PASS }}
|
||||
run: |
|
||||
./tools/generate-release.sh ${{ secrets.STORE_PASS }}
|
||||
./tools/generate-fdroid-release.sh ${{ secrets.FDROID_STORE_PASS }}
|
||||
|
||||
- uses: actions/github-script@v6
|
||||
with:
|
||||
|
@ -48,6 +51,7 @@ jobs:
|
|||
const artifacts = {
|
||||
bundle: '${{ github.workspace }}/app/build/outputs/bundle/release/app-release.aab',
|
||||
mapping: '${{ github.workspace }}/app/build/outputs/mapping/release/mapping.txt',
|
||||
fossApkPath: '${{ github.workspace }}/app/build/outputs/apk/release/app-foss-release-signed.apk',
|
||||
}
|
||||
await publishRelease(github, artifacts)
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
name: Nightly
|
||||
name: Release Train
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="app.dapk.st">
|
||||
package="app.dapk.st">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
|
||||
<application
|
||||
android:name="app.dapk.st.SmallTalkApplication"
|
||||
|
@ -17,13 +19,13 @@
|
|||
android:exported="true"
|
||||
android:targetActivity="app.dapk.st.home.MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
android:resource="@xml/shortcuts"/>
|
||||
|
||||
</activity-alias>
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ import app.dapk.st.graph.AppModule
|
|||
import app.dapk.st.home.HomeModule
|
||||
import app.dapk.st.login.LoginModule
|
||||
import app.dapk.st.messenger.MessengerModule
|
||||
import app.dapk.st.messenger.gallery.ImageGalleryModule
|
||||
import app.dapk.st.notifications.NotificationsModule
|
||||
import app.dapk.st.profile.ProfileModule
|
||||
import app.dapk.st.push.PushModule
|
||||
|
@ -54,7 +55,9 @@ class SmallTalkApplication : Application(), ModuleProvider {
|
|||
|
||||
private fun onApplicationLaunch(notificationsModule: NotificationsModule, storeModule: StoreModule) {
|
||||
applicationScope.launch {
|
||||
featureModules.pushModule.pushTokenRegistrar().registerCurrentToken()
|
||||
storeModule.credentialsStore().credentials()?.let {
|
||||
featureModules.pushModule.pushTokenRegistrar().registerCurrentToken()
|
||||
}
|
||||
storeModule.localEchoStore.preload()
|
||||
}
|
||||
|
||||
|
@ -79,6 +82,7 @@ class SmallTalkApplication : Application(), ModuleProvider {
|
|||
TaskRunnerModule::class -> appModule.domainModules.taskRunnerModule
|
||||
CoreAndroidModule::class -> appModule.coreAndroidModule
|
||||
ShareEntryModule::class -> featureModules.shareEntryModule
|
||||
ImageGalleryModule::class -> featureModules.imageGalleryModule
|
||||
else -> throw IllegalArgumentException("Unknown: $klass")
|
||||
} as T
|
||||
}
|
||||
|
|
|
@ -43,6 +43,7 @@ import app.dapk.st.matrix.sync.internal.request.ApiToDeviceEvent
|
|||
import app.dapk.st.matrix.sync.internal.room.MessageDecrypter
|
||||
import app.dapk.st.messenger.MessengerActivity
|
||||
import app.dapk.st.messenger.MessengerModule
|
||||
import app.dapk.st.messenger.gallery.ImageGalleryModule
|
||||
import app.dapk.st.navigator.IntentFactory
|
||||
import app.dapk.st.navigator.MessageAttachment
|
||||
import app.dapk.st.notifications.MatrixPushHandler
|
||||
|
@ -217,6 +218,10 @@ internal class FeatureModules internal constructor(
|
|||
ShareEntryModule(matrixModules.sync, matrixModules.room)
|
||||
}
|
||||
|
||||
val imageGalleryModule by unsafeLazy {
|
||||
ImageGalleryModule(context.contentResolver, coroutineDispatchers)
|
||||
}
|
||||
|
||||
val pushModule by unsafeLazy {
|
||||
domainModules.pushModule
|
||||
}
|
||||
|
|
|
@ -132,7 +132,7 @@ ext.kotlinTest = { dependencies ->
|
|||
dependencies.testImplementation Dependencies.mavenCentral.kluent
|
||||
dependencies.testImplementation Dependencies.mavenCentral.kotlinTest
|
||||
dependencies.testImplementation "org.jetbrains.kotlin:kotlin-test-junit:1.6.10"
|
||||
dependencies.testImplementation 'io.mockk:mockk:1.12.8'
|
||||
dependencies.testImplementation 'io.mockk:mockk:1.13.2'
|
||||
dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
|
||||
|
||||
dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.1'
|
||||
|
@ -140,7 +140,7 @@ ext.kotlinTest = { dependencies ->
|
|||
}
|
||||
|
||||
ext.kotlinFixtures = { dependencies ->
|
||||
dependencies.testFixturesImplementation 'io.mockk:mockk:1.12.7'
|
||||
dependencies.testFixturesImplementation 'io.mockk:mockk:1.13.1'
|
||||
dependencies.testFixturesImplementation Dependencies.mavenCentral.kluent
|
||||
dependencies.testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore
|
||||
}
|
||||
|
|
|
@ -82,10 +82,12 @@ ext.Dependencies.with {
|
|||
includeGroup "io.ktor"
|
||||
includeGroup "io.coil-kt"
|
||||
includeGroup "io.mockk"
|
||||
includeGroup "io.perfmark"
|
||||
includeGroup "info.picocli"
|
||||
includeGroup "us.fatehi"
|
||||
includeGroup "jakarta.xml.bind"
|
||||
includeGroup "jakarta.activation"
|
||||
includeGroup "javax.annotation"
|
||||
includeGroup "javax.inject"
|
||||
includeGroup "junit"
|
||||
includeGroup "jline"
|
||||
|
@ -102,14 +104,14 @@ ext.Dependencies.with {
|
|||
|
||||
google = new DependenciesContainer()
|
||||
google.with {
|
||||
androidGradlePlugin = "com.android.tools.build:gradle:7.2.2"
|
||||
androidGradlePlugin = "com.android.tools.build:gradle:7.3.0"
|
||||
|
||||
androidxComposeUi = "androidx.compose.ui:ui:${composeVer}"
|
||||
androidxComposeFoundation = "androidx.compose.foundation:foundation:${composeVer}"
|
||||
androidxComposeMaterial = "androidx.compose.material3:material3:1.0.0-beta01"
|
||||
androidxComposeIconsExtended = "androidx.compose.material:material-icons-extended:${composeVer}"
|
||||
androidxActivityCompose = "androidx.activity:activity-compose:1.4.0"
|
||||
kotlinCompilerExtensionVersion = "1.3.0"
|
||||
kotlinCompilerExtensionVersion = "1.3.1"
|
||||
|
||||
firebaseCrashlyticsPlugin = "com.google.firebase:firebase-crashlytics-gradle:2.9.1"
|
||||
jdkLibs = "com.android.tools:desugar_jdk_libs:1.1.5"
|
||||
|
@ -143,7 +145,7 @@ ext.Dependencies.with {
|
|||
|
||||
junit = "junit:junit:4.13.2"
|
||||
kluent = "org.amshove.kluent:kluent:1.68"
|
||||
mockk = 'io.mockk:mockk:1.12.8'
|
||||
mockk = 'io.mockk:mockk:1.13.2'
|
||||
|
||||
matrixOlm = "org.matrix.android:olm-sdk:3.2.12"
|
||||
}
|
||||
|
|
|
@ -31,8 +31,8 @@ private fun createExtended(scheme: ColorScheme) = ExtendedColors(
|
|||
onSelfBubble = scheme.onPrimary,
|
||||
othersBubble = scheme.secondaryContainer,
|
||||
onOthersBubble = scheme.onSecondaryContainer,
|
||||
selfBubbleReplyBackground = Color(0x40EAEAEA),
|
||||
otherBubbleReplyBackground = Color(0x20EAEAEA),
|
||||
selfBubbleReplyBackground = scheme.primary.copy(alpha = 0.2f),
|
||||
otherBubbleReplyBackground = scheme.primary.copy(alpha = 0.2f),
|
||||
missingImageColors = listOf(
|
||||
Color(0xFFf7c7f7) to Color(0xFFdf20de),
|
||||
Color(0xFFe5d7f6) to Color(0xFF7b30cf),
|
||||
|
|
|
@ -1,15 +1,19 @@
|
|||
package app.dapk.st.core
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import app.dapk.st.core.extensions.unsafeLazy
|
||||
import app.dapk.st.design.components.SmallTalkTheme
|
||||
import app.dapk.st.design.components.ThemeConfig
|
||||
import app.dapk.st.navigator.navigator
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import androidx.activity.compose.setContent as _setContent
|
||||
|
||||
abstract class DapkActivity : ComponentActivity(), EffectScope {
|
||||
|
@ -27,7 +31,6 @@ abstract class DapkActivity : ComponentActivity(), EffectScope {
|
|||
super.onCreate(savedInstanceState)
|
||||
this.themeConfig = ThemeConfig(themeStore.isMaterialYouEnabled())
|
||||
|
||||
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
|
||||
}
|
||||
|
@ -58,10 +61,40 @@ abstract class DapkActivity : ComponentActivity(), EffectScope {
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("OVERRIDE_DEPRECATION")
|
||||
override fun onBackPressed() {
|
||||
if (needsBackLeakWorkaround && !onBackPressedDispatcher.hasEnabledCallbacks()) {
|
||||
finishAfterTransition()
|
||||
} else
|
||||
} else {
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
protected suspend fun ensurePermission(permission: String): PermissionResult {
|
||||
return when {
|
||||
checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED -> PermissionResult.Granted
|
||||
|
||||
shouldShowRequestPermissionRationale(permission) -> PermissionResult.ShowRational
|
||||
|
||||
else -> {
|
||||
val isGranted = suspendCancellableCoroutine { continuation ->
|
||||
val callback: (result: Boolean) -> Unit = { result -> continuation.resume(result) }
|
||||
val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission(), callback)
|
||||
launcher.launch(permission)
|
||||
continuation.invokeOnCancellation { launcher.unregister() }
|
||||
}
|
||||
|
||||
when (isGranted) {
|
||||
true -> PermissionResult.Granted
|
||||
false -> PermissionResult.Denied
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface PermissionResult {
|
||||
object Granted : PermissionResult
|
||||
object ShowRational : PermissionResult
|
||||
object Denied : PermissionResult
|
||||
}
|
||||
|
|
|
@ -3,5 +3,5 @@ plugins {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly 'org.json:json:20220320'
|
||||
compileOnly 'org.json:json:20220924'
|
||||
}
|
|
@ -170,6 +170,8 @@ class OlmWrapper(
|
|||
val inBound = OlmInboundGroupSession(roomCryptoSession.key)
|
||||
olmStore.persist(roomCryptoSession.id, inBound)
|
||||
|
||||
logger.crypto("Creating megolm: ${roomCryptoSession.id}")
|
||||
|
||||
return roomCryptoSession
|
||||
}
|
||||
|
||||
|
@ -181,7 +183,7 @@ class OlmWrapper(
|
|||
private suspend fun deviceCrypto(input: OlmSessionInput): DeviceCryptoSession? {
|
||||
return olmStore.readSessions(listOf(input.identity))?.let {
|
||||
DeviceCryptoSession(
|
||||
input.deviceId, input.userId, input.identity, input.fingerprint, it
|
||||
input.deviceId, input.userId, input.identity, input.fingerprint, it.map { it.second }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,10 +27,10 @@ internal class RoomPersistence(
|
|||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
) : RoomStore {
|
||||
|
||||
override suspend fun persist(roomId: RoomId, state: RoomState) {
|
||||
override suspend fun persist(roomId: RoomId, events: List<RoomEvent>) {
|
||||
coroutineDispatchers.withIoContext {
|
||||
database.transaction {
|
||||
state.events.forEach {
|
||||
events.forEach {
|
||||
database.roomEventQueries.insertRoomEvent(roomId, it)
|
||||
}
|
||||
}
|
||||
|
@ -38,9 +38,16 @@ internal class RoomPersistence(
|
|||
}
|
||||
|
||||
override suspend fun remove(rooms: List<RoomId>) {
|
||||
coroutineDispatchers
|
||||
database.roomEventQueries.transaction {
|
||||
rooms.forEach { database.roomEventQueries.remove(it.value) }
|
||||
coroutineDispatchers.withIoContext {
|
||||
database.roomEventQueries.transaction {
|
||||
rooms.forEach { database.roomEventQueries.remove(it.value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun remove(eventId: EventId) {
|
||||
coroutineDispatchers.withIoContext {
|
||||
database.roomEventQueries.removeEvent(eventId.value)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -36,4 +36,8 @@ LIMIT 100;
|
|||
|
||||
remove:
|
||||
DELETE FROM dbRoomEvent
|
||||
WHERE room_id = ?;
|
||||
WHERE room_id = ?;
|
||||
|
||||
removeEvent:
|
||||
DELETE FROM dbRoomEvent
|
||||
WHERE event_id = ?;
|
|
@ -13,4 +13,5 @@ dependencies {
|
|||
implementation project(':domains:store')
|
||||
implementation project(":core")
|
||||
implementation project(":design-library")
|
||||
implementation Dependencies.mavenCentral.coil
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="app.dapk.st.home">
|
||||
|
||||
<application>
|
||||
<activity android:name="app.dapk.st.home.MainActivity"/>
|
||||
</application>
|
||||
<application>
|
||||
<activity android:name="app.dapk.st.home.MainActivity"/>
|
||||
</application>
|
||||
|
||||
</manifest>
|
|
@ -27,6 +27,7 @@ class MainActivity : DapkActivity() {
|
|||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
homeViewModel.events.onEach {
|
||||
when (it) {
|
||||
HomeEvent.Relaunch -> recreate()
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="app.dapk.st.messenger">
|
||||
package="app.dapk.st.messenger">
|
||||
|
||||
<application>
|
||||
|
||||
<activity
|
||||
android:name=".MessengerActivity"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
android:windowSoftInputMode="adjustResize"/>
|
||||
|
||||
<activity android:name=".roomsettings.RoomSettingsActivity" />
|
||||
<activity android:name=".roomsettings.RoomSettingsActivity"/>
|
||||
<activity android:name=".gallery.ImageGalleryActivity"/>
|
||||
|
||||
</application>
|
||||
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
package app.dapk.st.messenger
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import app.dapk.st.core.Base64
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.crypto.MediaDecrypter
|
||||
import app.dapk.st.matrix.sync.RoomEvent
|
||||
import coil.ImageLoader
|
||||
|
@ -14,14 +16,16 @@ import coil.request.Options
|
|||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import okio.BufferedSource
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import java.io.File
|
||||
|
||||
class DecryptingFetcherFactory(private val context: Context, base64: Base64) : Fetcher.Factory<RoomEvent.Image> {
|
||||
class DecryptingFetcherFactory(private val context: Context, base64: Base64, private val roomId: RoomId) : Fetcher.Factory<RoomEvent.Image> {
|
||||
|
||||
private val mediaDecrypter = MediaDecrypter(base64)
|
||||
|
||||
override fun create(data: RoomEvent.Image, options: Options, imageLoader: ImageLoader): Fetcher {
|
||||
return DecryptingFetcher(data, context, mediaDecrypter)
|
||||
return DecryptingFetcher(data, context, mediaDecrypter, roomId)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -31,23 +35,48 @@ class DecryptingFetcher(
|
|||
private val data: RoomEvent.Image,
|
||||
private val context: Context,
|
||||
private val mediaDecrypter: MediaDecrypter,
|
||||
roomId: RoomId,
|
||||
) : Fetcher {
|
||||
|
||||
override suspend fun fetch(): FetchResult {
|
||||
val response = http.newCall(Request.Builder().url(data.imageMeta.url).build()).execute()
|
||||
val outputStream = when {
|
||||
data.imageMeta.keys != null -> handleEncrypted(response, data.imageMeta.keys!!)
|
||||
else -> response.body?.source() ?: throw IllegalArgumentException("No bitmap response found")
|
||||
}
|
||||
return SourceResult(ImageSource(outputStream, context), null, DataSource.NETWORK)
|
||||
private val directory by lazy {
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)!!.resolve("SmallTalk/${roomId.value}").also { it.mkdirs() }
|
||||
}
|
||||
|
||||
private fun handleEncrypted(response: Response, keys: RoomEvent.Image.ImageMeta.Keys): Buffer {
|
||||
return response.body?.byteStream()?.let { byteStream ->
|
||||
Buffer().also { buffer ->
|
||||
mediaDecrypter.decrypt(byteStream, keys.k, keys.iv).collect { buffer.write(it) }
|
||||
override suspend fun fetch(): FetchResult {
|
||||
val diskCacheKey = data.imageMeta.url.hashCode().toString()
|
||||
val diskCachedFile = directory.resolve(diskCacheKey)
|
||||
val path = diskCachedFile.toOkioPath()
|
||||
|
||||
return when {
|
||||
diskCachedFile.exists() -> SourceResult(ImageSource(path), null, DataSource.DISK)
|
||||
|
||||
else -> {
|
||||
diskCachedFile.createNewFile()
|
||||
val response = http.newCall(Request.Builder().url(data.imageMeta.url).build()).execute()
|
||||
when {
|
||||
data.imageMeta.keys != null -> response.writeDecrypted(diskCachedFile, data.imageMeta.keys!!)
|
||||
else -> response.body?.source()?.writeToFile(diskCachedFile) ?: throw IllegalArgumentException("No bitmap response found")
|
||||
}
|
||||
|
||||
SourceResult(ImageSource(path), null, DataSource.NETWORK)
|
||||
}
|
||||
} ?: Buffer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Response.writeDecrypted(file: File, keys: RoomEvent.Image.ImageMeta.Keys) {
|
||||
this.body?.byteStream()?.let { byteStream ->
|
||||
file.outputStream().use { output ->
|
||||
mediaDecrypter.decrypt(byteStream, keys.k, keys.iv).collect { output.write(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun BufferedSource.writeToFile(file: File) {
|
||||
this.inputStream().use { input ->
|
||||
file.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5,16 +5,16 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import app.dapk.st.core.DapkActivity
|
||||
import app.dapk.st.core.*
|
||||
import app.dapk.st.core.extensions.unsafeLazy
|
||||
import app.dapk.st.core.module
|
||||
import app.dapk.st.core.viewModel
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.messenger.gallery.GetImageFromGallery
|
||||
import app.dapk.st.navigator.MessageAttachment
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
|
@ -50,11 +50,26 @@ class MessengerActivity : DapkActivity() {
|
|||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val payload = readPayload<MessagerActivityPayload>()
|
||||
val factory = module.decryptingFetcherFactory()
|
||||
val factory = module.decryptingFetcherFactory(RoomId(payload.roomId))
|
||||
|
||||
val galleryLauncher = registerForActivityResult(GetImageFromGallery()) {
|
||||
it?.let { uri ->
|
||||
viewModel.post(
|
||||
MessengerAction.ComposerImageUpdate(
|
||||
MessageAttachment(
|
||||
AndroidUri(it.toString()),
|
||||
MimeType.Image,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setContent {
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
CompositionLocalProvider(LocalDecyptingFetcherFactory provides factory) {
|
||||
MessengerScreen(RoomId(payload.roomId), payload.attachments, viewModel, navigator)
|
||||
MessengerScreen(RoomId(payload.roomId), payload.attachments, viewModel, navigator, galleryLauncher)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||
import app.dapk.st.core.Base64
|
||||
import app.dapk.st.core.ProvidableModule
|
||||
import app.dapk.st.matrix.common.CredentialsStore
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.message.MessageService
|
||||
import app.dapk.st.matrix.room.RoomService
|
||||
import app.dapk.st.matrix.sync.RoomStore
|
||||
|
@ -30,5 +31,5 @@ class MessengerModule(
|
|||
return TimelineUseCaseImpl(syncService, messageService, roomService, mergeWithLocalEchosUseCase)
|
||||
}
|
||||
|
||||
internal fun decryptingFetcherFactory() = DecryptingFetcherFactory(context, base64)
|
||||
internal fun decryptingFetcherFactory(roomId: RoomId) = DecryptingFetcherFactory(context, base64, roomId)
|
||||
}
|
|
@ -1,9 +1,11 @@
|
|||
package app.dapk.st.messenger
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
|
@ -13,6 +15,7 @@ import androidx.compose.foundation.text.KeyboardOptions
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
|
@ -33,6 +36,7 @@ import app.dapk.st.core.Lce
|
|||
import app.dapk.st.core.LifecycleEffect
|
||||
import app.dapk.st.core.StartObserving
|
||||
import app.dapk.st.core.components.CenteredLoading
|
||||
import app.dapk.st.core.extensions.takeIfContent
|
||||
import app.dapk.st.design.components.*
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.common.UserId
|
||||
|
@ -40,6 +44,7 @@ import app.dapk.st.matrix.sync.MessageMeta
|
|||
import app.dapk.st.matrix.sync.RoomEvent
|
||||
import app.dapk.st.matrix.sync.RoomEvent.Message
|
||||
import app.dapk.st.matrix.sync.RoomState
|
||||
import app.dapk.st.messenger.gallery.ImageGalleryActivityPayload
|
||||
import app.dapk.st.navigator.MessageAttachment
|
||||
import app.dapk.st.navigator.Navigator
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
|
@ -47,10 +52,16 @@ import coil.request.ImageRequest
|
|||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
internal fun MessengerScreen(roomId: RoomId, attachments: List<MessageAttachment>?, viewModel: MessengerViewModel, navigator: Navigator) {
|
||||
internal fun MessengerScreen(
|
||||
roomId: RoomId,
|
||||
attachments: List<MessageAttachment>?,
|
||||
viewModel: MessengerViewModel,
|
||||
navigator: Navigator,
|
||||
galleryLauncher: ActivityResultLauncher<ImageGalleryActivityPayload>
|
||||
) {
|
||||
val state = viewModel.state
|
||||
|
||||
viewModel.ObserveEvents()
|
||||
viewModel.ObserveEvents(galleryLauncher)
|
||||
LifecycleEffect(
|
||||
onStart = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) },
|
||||
onStop = { viewModel.post(MessengerAction.OnMessengerGone) }
|
||||
|
@ -63,9 +74,9 @@ internal fun MessengerScreen(roomId: RoomId, attachments: List<MessageAttachment
|
|||
|
||||
Column {
|
||||
Toolbar(onNavigate = { navigator.navigate.upToHome() }, roomTitle, actions = {
|
||||
OverflowMenu {
|
||||
DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {})
|
||||
}
|
||||
// OverflowMenu {
|
||||
// DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {})
|
||||
// }
|
||||
})
|
||||
when (state.composerState) {
|
||||
is ComposerState.Text -> {
|
||||
|
@ -74,6 +85,7 @@ internal fun MessengerScreen(roomId: RoomId, attachments: List<MessageAttachment
|
|||
state.composerState,
|
||||
onTextChange = { viewModel.post(MessengerAction.ComposerTextUpdate(it)) },
|
||||
onSend = { viewModel.post(MessengerAction.ComposerSendText) },
|
||||
onAttach = { viewModel.startAttachment() }
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -89,10 +101,16 @@ internal fun MessengerScreen(roomId: RoomId, attachments: List<MessageAttachment
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun MessengerViewModel.ObserveEvents() {
|
||||
private fun MessengerViewModel.ObserveEvents(galleryLauncher: ActivityResultLauncher<ImageGalleryActivityPayload>) {
|
||||
StartObserving {
|
||||
this@ObserveEvents.events.launch {
|
||||
// TODO()
|
||||
when (it) {
|
||||
MessengerEvent.SelectImageAttachment -> {
|
||||
state.roomState.takeIfContent()?.let {
|
||||
galleryLauncher.launch(ImageGalleryActivityPayload(it.roomState.roomOverview.roomName ?: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -258,6 +276,7 @@ private fun MessageImage(content: BubbleContent<RoomEvent.Image>) {
|
|||
painter = rememberAsyncImagePainter(
|
||||
model = ImageRequest.Builder(context)
|
||||
.fetcherFactory(LocalDecyptingFetcherFactory.current)
|
||||
.memoryCacheKey(content.message.imageMeta.url)
|
||||
.data(content.message)
|
||||
.build()
|
||||
),
|
||||
|
@ -408,6 +427,7 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
|
|||
val context = LocalContext.current
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (content.isNotSelf) SmallTalkTheme.extendedColors.otherBubbleReplyBackground else SmallTalkTheme.extendedColors.selfBubbleReplyBackground)
|
||||
.padding(4.dp)
|
||||
) {
|
||||
|
@ -417,13 +437,13 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
|
|||
fontSize = 11.sp,
|
||||
text = replyName,
|
||||
maxLines = 1,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
color = content.textColor()
|
||||
)
|
||||
when (val replyingTo = content.message.replyingTo) {
|
||||
is Message -> {
|
||||
Text(
|
||||
text = replyingTo.content,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
color = content.textColor(),
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
textAlign = TextAlign.Start,
|
||||
|
@ -437,6 +457,7 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
|
|||
painter = rememberAsyncImagePainter(
|
||||
model = ImageRequest.Builder(context)
|
||||
.fetcherFactory(LocalDecyptingFetcherFactory.current)
|
||||
.memoryCacheKey(replyingTo.imageMeta.url)
|
||||
.data(replyingTo)
|
||||
.build()
|
||||
),
|
||||
|
@ -478,7 +499,8 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
|
|||
modifier = Modifier.size(message.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)),
|
||||
painter = rememberAsyncImagePainter(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(content.message)
|
||||
.data(message)
|
||||
.memoryCacheKey(message.imageMeta.url)
|
||||
.fetcherFactory(LocalDecyptingFetcherFactory.current)
|
||||
.build()
|
||||
),
|
||||
|
@ -550,7 +572,7 @@ private fun RowScope.SendStatus(message: RoomEvent) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Unit, onSend: () -> Unit) {
|
||||
private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Unit, onSend: () -> Unit, onAttach: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
@ -576,7 +598,17 @@ private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Un
|
|||
onValueChange = { onTextChange(it) },
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
textStyle = LocalTextStyle.current.copy(color = SmallTalkTheme.extendedColors.onOthersBubble),
|
||||
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, autoCorrect = true)
|
||||
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, autoCorrect = true),
|
||||
decorationBox = { innerField ->
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Box(modifier = Modifier.weight(1f).padding(end = 4.dp)) { innerField() }
|
||||
Icon(
|
||||
modifier = Modifier.clickable { onAttach() }.wrapContentWidth().align(Alignment.Bottom),
|
||||
imageVector = Icons.Filled.Image,
|
||||
contentDescription = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,9 @@ data class MessengerScreenState(
|
|||
val composerState: ComposerState,
|
||||
)
|
||||
|
||||
sealed interface MessengerEvent
|
||||
sealed interface MessengerEvent {
|
||||
object SelectImageAttachment : MessengerEvent
|
||||
}
|
||||
|
||||
sealed interface ComposerState {
|
||||
|
||||
|
|
|
@ -48,6 +48,7 @@ internal class MessengerViewModel(
|
|||
is MessengerAction.ComposerTextUpdate -> updateState { copy(composerState = ComposerState.Text(action.newValue)) }
|
||||
MessengerAction.ComposerSendText -> sendMessage()
|
||||
MessengerAction.ComposerClear -> updateState { copy(composerState = ComposerState.Text("")) }
|
||||
is MessengerAction.ComposerImageUpdate -> updateState { copy(composerState = ComposerState.Attachments(listOf(action.newValue))) }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -100,6 +101,7 @@ internal class MessengerViewModel(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
is ComposerState.Attachments -> {
|
||||
val copy = composerState.copy()
|
||||
updateState { copy(composerState = ComposerState.Text("")) }
|
||||
|
@ -123,6 +125,12 @@ internal class MessengerViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
fun startAttachment() {
|
||||
viewModelScope.launch {
|
||||
_events.emit(MessengerEvent.SelectImageAttachment)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun MessengerState.latestMessageEventFromOthers(self: UserId) = this.roomState.events
|
||||
|
@ -133,6 +141,7 @@ private fun MessengerState.latestMessageEventFromOthers(self: UserId) = this.roo
|
|||
|
||||
sealed interface MessengerAction {
|
||||
data class ComposerTextUpdate(val newValue: String) : MessengerAction
|
||||
data class ComposerImageUpdate(val newValue: MessageAttachment) : MessengerAction
|
||||
object ComposerSendText : MessengerAction
|
||||
object ComposerClear : MessengerAction
|
||||
data class OnMessengerVisible(val roomId: RoomId, val attachments: List<MessageAttachment>?) : MessengerAction
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
package app.dapk.st.messenger.gallery
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore.Images
|
||||
import app.dapk.st.core.CoroutineDispatchers
|
||||
import app.dapk.st.core.withIoContext
|
||||
|
||||
class FetchMediaFoldersUseCase(
|
||||
private val contentResolver: ContentResolver,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
|
||||
suspend fun fetchFolders(): List<Folder> {
|
||||
return dispatchers.withIoContext {
|
||||
val projection = arrayOf(Images.Media._ID, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_MODIFIED)
|
||||
val selection = "${isNotPending()} AND ${Images.Media.BUCKET_ID} AND ${Images.Media.MIME_TYPE} NOT LIKE ?"
|
||||
val sortBy = "${Images.Media.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC, ${Images.Media.DATE_MODIFIED} DESC"
|
||||
|
||||
val folders = mutableMapOf<String, Folder>()
|
||||
val contentUri = Images.Media.EXTERNAL_CONTENT_URI
|
||||
contentResolver.query(contentUri, projection, selection, arrayOf("%image/svg%"), sortBy).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0]))
|
||||
val thumbnail = ContentUris.withAppendedId(contentUri, rowId)
|
||||
val bucketId = cursor.getString(cursor.getColumnIndexOrThrow(projection[1]))
|
||||
val title = cursor.getString(cursor.getColumnIndexOrThrow(projection[2])) ?: ""
|
||||
val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(projection[3]))
|
||||
val folder = folders.getOrPut(bucketId) { Folder(bucketId, title, thumbnail) }
|
||||
folder.incrementItemCount()
|
||||
}
|
||||
}
|
||||
folders.values.toList()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
data class Folder(
|
||||
val bucketId: String,
|
||||
val title: String,
|
||||
val thumbnail: Uri,
|
||||
) {
|
||||
private var _itemCount: Long = 0L
|
||||
val itemCount: Long
|
||||
get() = _itemCount
|
||||
|
||||
fun incrementItemCount() {
|
||||
_itemCount++
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
package app.dapk.st.messenger.gallery
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import app.dapk.st.core.CoroutineDispatchers
|
||||
import app.dapk.st.core.withIoContext
|
||||
|
||||
class FetchMediaUseCase(private val contentResolver: ContentResolver, private val dispatchers: CoroutineDispatchers) {
|
||||
|
||||
private val projection = arrayOf(
|
||||
MediaStore.Images.Media._ID,
|
||||
MediaStore.Images.Media.MIME_TYPE,
|
||||
MediaStore.Images.Media.DATE_MODIFIED,
|
||||
MediaStore.Images.Media.ORIENTATION,
|
||||
MediaStore.Images.Media.WIDTH,
|
||||
MediaStore.Images.Media.HEIGHT,
|
||||
MediaStore.Images.Media.SIZE
|
||||
)
|
||||
|
||||
private val selection = MediaStore.Images.Media.BUCKET_ID + " = ? AND " + isNotPending() + " AND " + MediaStore.Images.Media.MIME_TYPE + " NOT LIKE ?"
|
||||
|
||||
suspend fun getMediaInBucket(bucketId: String): List<Media> {
|
||||
|
||||
return dispatchers.withIoContext {
|
||||
val media = mutableListOf<Media>()
|
||||
val selectionArgs = arrayOf(bucketId, "%image/svg%")
|
||||
val sortBy = MediaStore.Images.Media.DATE_MODIFIED + " DESC"
|
||||
val contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
contentResolver.query(contentUri, projection, selection, selectionArgs, sortBy).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0]))
|
||||
val uri = ContentUris.withAppendedId(contentUri, rowId)
|
||||
val mimetype = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE))
|
||||
val date = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED))
|
||||
val orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION))
|
||||
val width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation)))
|
||||
val height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation)))
|
||||
val size = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE))
|
||||
media.add(Media(rowId, uri, mimetype, width, height, size, date))
|
||||
}
|
||||
}
|
||||
media
|
||||
}
|
||||
}
|
||||
|
||||
private fun getWidthColumn(orientation: Int) = if (orientation == 0 || orientation == 180) MediaStore.Images.Media.WIDTH else MediaStore.Images.Media.HEIGHT
|
||||
|
||||
private fun getHeightColumn(orientation: Int) =
|
||||
if (orientation == 0 || orientation == 180) MediaStore.Images.Media.HEIGHT else MediaStore.Images.Media.WIDTH
|
||||
|
||||
}
|
||||
|
||||
data class Media(
|
||||
val id: Long,
|
||||
val uri: Uri,
|
||||
val mimeType: String,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val size: Long,
|
||||
val dateModifiedEpochMillis: Long,
|
||||
)
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
package app.dapk.st.messenger.gallery
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import app.dapk.st.core.*
|
||||
import app.dapk.st.core.extensions.unsafeLazy
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
class ImageGalleryActivity : DapkActivity() {
|
||||
|
||||
private val module by unsafeLazy { module<ImageGalleryModule>() }
|
||||
private val viewModel by viewModel {
|
||||
val payload = intent.getParcelableExtra("key") as? ImageGalleryActivityPayload
|
||||
module.imageGalleryViewModel(payload!!.roomName)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val permissionState = mutableStateOf<Lce<PermissionResult>>(Lce.Loading())
|
||||
|
||||
lifecycleScope.launch {
|
||||
permissionState.value = runCatching { ensurePermission(Manifest.permission.READ_EXTERNAL_STORAGE) }.fold(
|
||||
onSuccess = { Lce.Content(it) },
|
||||
onFailure = { Lce.Error(it) }
|
||||
)
|
||||
}
|
||||
|
||||
setContent {
|
||||
Surface {
|
||||
PermissionGuard(permissionState) {
|
||||
ImageGalleryScreen(viewModel, onTopLevelBack = { finish() }) { media ->
|
||||
setResult(RESULT_OK, Intent().setData(media.uri))
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Activity.PermissionGuard(state: State<Lce<PermissionResult>>, onGranted: @Composable () -> Unit) {
|
||||
when (val content = state.value) {
|
||||
is Lce.Content -> when (content.value) {
|
||||
PermissionResult.Granted -> onGranted()
|
||||
PermissionResult.Denied -> finish()
|
||||
PermissionResult.ShowRational -> finish()
|
||||
}
|
||||
|
||||
is Lce.Error -> finish()
|
||||
is Lce.Loading -> {
|
||||
// loading should be quick, let's avoid displaying anything
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class GetImageFromGallery : ActivityResultContract<ImageGalleryActivityPayload, Uri?>() {
|
||||
|
||||
override fun createIntent(context: Context, input: ImageGalleryActivityPayload): Intent {
|
||||
return Intent(context, ImageGalleryActivity::class.java)
|
||||
.putExtra("key", input)
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
|
||||
return intent.takeIf { resultCode == Activity.RESULT_OK }?.data
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Parcelize
|
||||
data class ImageGalleryActivityPayload(
|
||||
val roomName: String,
|
||||
) : Parcelable
|
|
@ -0,0 +1,18 @@
|
|||
package app.dapk.st.messenger.gallery
|
||||
|
||||
import android.content.ContentResolver
|
||||
import app.dapk.st.core.CoroutineDispatchers
|
||||
import app.dapk.st.core.ProvidableModule
|
||||
|
||||
class ImageGalleryModule(
|
||||
private val contentResolver: ContentResolver,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : ProvidableModule {
|
||||
|
||||
fun imageGalleryViewModel(roomName: String) = ImageGalleryViewModel(
|
||||
FetchMediaFoldersUseCase(contentResolver, dispatchers),
|
||||
FetchMediaUseCase(contentResolver, dispatchers),
|
||||
roomName = roomName,
|
||||
)
|
||||
|
||||
}
|
|
@ -0,0 +1,157 @@
|
|||
package app.dapk.st.messenger.gallery
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.core.LifecycleEffect
|
||||
import app.dapk.st.core.components.CenteredLoading
|
||||
import app.dapk.st.design.components.GenericError
|
||||
import app.dapk.st.design.components.Spider
|
||||
import app.dapk.st.design.components.SpiderPage
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
|
||||
@Composable
|
||||
fun ImageGalleryScreen(viewModel: ImageGalleryViewModel, onTopLevelBack: () -> Unit, onImageSelected: (Media) -> Unit) {
|
||||
LifecycleEffect(onStart = {
|
||||
viewModel.start()
|
||||
})
|
||||
|
||||
val onNavigate: (SpiderPage<out ImageGalleryPage>?) -> Unit = {
|
||||
when (it) {
|
||||
null -> onTopLevelBack()
|
||||
else -> viewModel.goTo(it)
|
||||
}
|
||||
}
|
||||
|
||||
Spider(currentPage = viewModel.state.page, onNavigate = onNavigate) {
|
||||
item(ImageGalleryPage.Routes.folders) {
|
||||
ImageGalleryFolders(it) { folder ->
|
||||
viewModel.selectFolder(folder)
|
||||
}
|
||||
}
|
||||
item(ImageGalleryPage.Routes.files) {
|
||||
ImageGalleryMedia(it, onImageSelected)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Unit) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp
|
||||
|
||||
val gradient = Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.5f)),
|
||||
)
|
||||
|
||||
when (val content = state.content) {
|
||||
is Lce.Loading -> {
|
||||
CenteredLoading()
|
||||
}
|
||||
|
||||
is Lce.Content -> {
|
||||
Column {
|
||||
val columns = when {
|
||||
screenWidth > 600 -> 4
|
||||
else -> 2
|
||||
}
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(columns),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(content.value, key = { it.bucketId }) {
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(2.dp).aspectRatio(1f)
|
||||
.clickable { onClick(it) }) {
|
||||
Image(
|
||||
painter = rememberAsyncImagePainter(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(it.thumbnail.toString())
|
||||
.build(),
|
||||
),
|
||||
contentDescription = "123",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth().fillMaxHeight(0.6f).background(gradient).align(Alignment.BottomStart))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.BottomStart).padding(4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(it.title, fontSize = 13.sp, color = Color.White)
|
||||
Text(it.itemCount.toString(), fontSize = 11.sp, color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is Lce.Error -> GenericError { }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageGalleryMedia(state: ImageGalleryPage.Files, onFileSelected: (Media) -> Unit) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp
|
||||
|
||||
Column {
|
||||
val columns = when {
|
||||
screenWidth > 600 -> 4
|
||||
else -> 2
|
||||
}
|
||||
|
||||
when (val content = state.content) {
|
||||
is Lce.Loading -> {
|
||||
CenteredLoading()
|
||||
}
|
||||
|
||||
is Lce.Content -> {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(columns),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
val modifier = Modifier.fillMaxWidth().padding(2.dp).aspectRatio(1f)
|
||||
items(content.value, key = { it.id }) {
|
||||
Box(modifier = modifier.clickable { onFileSelected(it) }) {
|
||||
Image(
|
||||
painter = rememberAsyncImagePainter(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(it.uri.toString())
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
),
|
||||
contentDescription = "123",
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is Lce.Error -> GenericError { }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
package app.dapk.st.messenger.gallery
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.design.components.Route
|
||||
import app.dapk.st.design.components.SpiderPage
|
||||
import app.dapk.st.viewmodel.DapkViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ImageGalleryViewModel(
|
||||
private val foldersUseCase: FetchMediaFoldersUseCase,
|
||||
private val fetchMediaUseCase: FetchMediaUseCase,
|
||||
roomName: String,
|
||||
) : DapkViewModel<ImageGalleryState, ImageGalleryEvent>(
|
||||
initialState = ImageGalleryState(
|
||||
page = SpiderPage(
|
||||
route = ImageGalleryPage.Routes.folders,
|
||||
label = "Send to $roomName",
|
||||
parent = null,
|
||||
state = ImageGalleryPage.Folders(Lce.Loading())
|
||||
)
|
||||
)
|
||||
) {
|
||||
|
||||
private var currentPageJob: Job? = null
|
||||
|
||||
fun start() {
|
||||
currentPageJob?.cancel()
|
||||
currentPageJob = viewModelScope.launch {
|
||||
val folders = foldersUseCase.fetchFolders()
|
||||
updatePageState<ImageGalleryPage.Folders> { copy(content = Lce.Content(folders)) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun goTo(page: SpiderPage<out ImageGalleryPage>) {
|
||||
currentPageJob?.cancel()
|
||||
updateState { copy(page = page) }
|
||||
}
|
||||
|
||||
fun selectFolder(folder: Folder) {
|
||||
currentPageJob?.cancel()
|
||||
|
||||
updateState {
|
||||
copy(
|
||||
page = SpiderPage(
|
||||
route = ImageGalleryPage.Routes.files,
|
||||
label = page.label,
|
||||
parent = ImageGalleryPage.Routes.folders,
|
||||
state = ImageGalleryPage.Files(Lce.Loading())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
currentPageJob = viewModelScope.launch {
|
||||
val media = fetchMediaUseCase.getMediaInBucket(folder.bucketId)
|
||||
updatePageState<ImageGalleryPage.Files> {
|
||||
copy(content = Lce.Content(media))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified S : ImageGalleryPage> updatePageState(crossinline block: S.() -> S) {
|
||||
val page = state.page
|
||||
val currentState = page.state
|
||||
require(currentState is S)
|
||||
updateState { copy(page = (page as SpiderPage<S>).copy(state = block(page.state))) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
data class ImageGalleryState(
|
||||
val page: SpiderPage<out ImageGalleryPage>,
|
||||
)
|
||||
|
||||
|
||||
sealed interface ImageGalleryPage {
|
||||
data class Folders(val content: Lce<List<Folder>>) : ImageGalleryPage
|
||||
data class Files(val content: Lce<List<Media>>) : ImageGalleryPage
|
||||
|
||||
object Routes {
|
||||
val folders = Route<Folders>("Folders")
|
||||
val files = Route<Files>("Files")
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface ImageGalleryEvent
|
|
@ -0,0 +1,6 @@
|
|||
package app.dapk.st.messenger.gallery
|
||||
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
|
||||
fun isNotPending() = if (Build.VERSION.SDK_INT <= 28) MediaStore.Images.Media.DATA + " NOT NULL" else MediaStore.MediaColumns.IS_PENDING + " != 1"
|
|
@ -81,7 +81,7 @@ data class MessageAttachment(val uri: AndroidUri, val type: MimeType) : Parcelab
|
|||
private companion object : Parceler<MessageAttachment> {
|
||||
override fun create(parcel: Parcel): MessageAttachment {
|
||||
val uri = AndroidUri(parcel.readString()!!)
|
||||
val type = when(parcel.readString()!!) {
|
||||
val type = when (parcel.readString()!!) {
|
||||
"mimetype-image" -> MimeType.Image
|
||||
else -> throw IllegalStateException()
|
||||
}
|
||||
|
|
|
@ -43,7 +43,11 @@ data class ServiceDependencies(
|
|||
|
||||
interface MatrixServiceInstaller {
|
||||
fun serializers(builder: SerializersModuleBuilder.() -> Unit)
|
||||
fun install(factory: MatrixService.Factory)
|
||||
fun <T : MatrixService> install(factory: MatrixService.Factory): InstallExtender<T>
|
||||
}
|
||||
|
||||
interface InstallExtender<T : MatrixService> {
|
||||
fun proxy(proxy: (T) -> T)
|
||||
}
|
||||
|
||||
interface MatrixServiceProvider {
|
||||
|
|
|
@ -11,15 +11,22 @@ internal class ServiceInstaller {
|
|||
private val services = mutableMapOf<Any, MatrixService>()
|
||||
private val serviceInstaller = object : MatrixServiceInstaller {
|
||||
|
||||
val serviceCollector = mutableListOf<MatrixService.Factory>()
|
||||
val serviceCollector = mutableListOf<Pair<MatrixService.Factory, (MatrixService) -> MatrixService>>()
|
||||
val serializers = mutableListOf<SerializersModuleBuilder.() -> Unit>()
|
||||
|
||||
override fun serializers(builder: SerializersModuleBuilder.() -> Unit) {
|
||||
serializers.add(builder)
|
||||
}
|
||||
|
||||
override fun install(factory: MatrixService.Factory) {
|
||||
serviceCollector.add(factory)
|
||||
override fun <T : MatrixService> install(factory: MatrixService.Factory): InstallExtender<T> {
|
||||
val mutableProxy = MutableProxy<T>()
|
||||
return object : InstallExtender<T> {
|
||||
override fun proxy(proxy: (T) -> T) {
|
||||
mutableProxy.value = proxy
|
||||
}
|
||||
}.also {
|
||||
serviceCollector.add(factory to mutableProxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -39,9 +46,9 @@ internal class ServiceInstaller {
|
|||
val serviceProvider = object : MatrixServiceProvider {
|
||||
override fun <T : MatrixService> getService(key: ServiceKey) = this@ServiceInstaller.getService<T>(key)
|
||||
}
|
||||
serviceInstaller.serviceCollector.forEach {
|
||||
val (key, service) = it.create(ServiceDependencies(httpClient, json, serviceProvider, logger))
|
||||
services[key] = service
|
||||
serviceInstaller.serviceCollector.forEach { (factory, extender) ->
|
||||
val (key, service) = factory.create(ServiceDependencies(httpClient, json, serviceProvider, logger))
|
||||
services[key] = extender(service)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -57,4 +64,13 @@ internal class ServiceInstaller {
|
|||
?: throw IllegalArgumentException("No service available to handle ${task.type}")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal class MutableProxy<T : MatrixService> : (MatrixService) -> MatrixService {
|
||||
|
||||
var value: (T) -> T = { it }
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun invoke(service: MatrixService) = value(service as T)
|
||||
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package app.dapk.st.matrix.auth
|
||||
|
||||
import app.dapk.st.matrix.InstallExtender
|
||||
import app.dapk.st.matrix.MatrixClient
|
||||
import app.dapk.st.matrix.MatrixService
|
||||
import app.dapk.st.matrix.MatrixServiceInstaller
|
||||
|
@ -25,8 +26,8 @@ interface AuthService : MatrixService {
|
|||
|
||||
fun MatrixServiceInstaller.installAuthService(
|
||||
credentialsStore: CredentialsStore,
|
||||
) {
|
||||
this.install { (httpClient, json) ->
|
||||
): InstallExtender<AuthService> {
|
||||
return this.install { (httpClient, json) ->
|
||||
SERVICE_KEY to DefaultAuthService(httpClient, credentialsStore, json)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,10 +2,7 @@ package app.dapk.st.matrix.crypto
|
|||
|
||||
import app.dapk.st.core.Base64
|
||||
import app.dapk.st.core.CoroutineDispatchers
|
||||
import app.dapk.st.matrix.MatrixService
|
||||
import app.dapk.st.matrix.MatrixServiceInstaller
|
||||
import app.dapk.st.matrix.MatrixServiceProvider
|
||||
import app.dapk.st.matrix.ServiceDepFactory
|
||||
import app.dapk.st.matrix.*
|
||||
import app.dapk.st.matrix.common.*
|
||||
import app.dapk.st.matrix.crypto.internal.*
|
||||
import app.dapk.st.matrix.device.deviceService
|
||||
|
@ -136,8 +133,8 @@ fun MatrixServiceInstaller.installCryptoService(
|
|||
roomMembersProvider: ServiceDepFactory<RoomMembersProvider>,
|
||||
base64: Base64,
|
||||
coroutineDispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
this.install { (_, _, services, logger) ->
|
||||
): InstallExtender<CryptoService> {
|
||||
return this.install { (_, _, services, logger) ->
|
||||
val deviceService = services.deviceService()
|
||||
val accountCryptoUseCase = FetchAccountCryptoUseCaseImpl(credentialsStore, olm, deviceService)
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package app.dapk.st.matrix.device
|
||||
|
||||
import app.dapk.st.matrix.InstallExtender
|
||||
import app.dapk.st.matrix.MatrixService
|
||||
import app.dapk.st.matrix.MatrixServiceInstaller
|
||||
import app.dapk.st.matrix.MatrixServiceProvider
|
||||
|
@ -122,8 +123,8 @@ sealed class ToDevicePayload {
|
|||
sealed interface VerificationPayload
|
||||
}
|
||||
|
||||
fun MatrixServiceInstaller.installEncryptionService(knownDeviceStore: KnownDeviceStore) {
|
||||
this.install { (httpClient, _, _, logger) ->
|
||||
fun MatrixServiceInstaller.installEncryptionService(knownDeviceStore: KnownDeviceStore): InstallExtender<DeviceService> {
|
||||
return this.install { (httpClient, _, _, logger) ->
|
||||
SERVICE_KEY to DefaultDeviceService(httpClient, logger, knownDeviceStore)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
package app.dapk.st.matrix.message
|
||||
|
||||
import app.dapk.st.core.Base64
|
||||
import app.dapk.st.matrix.MatrixService
|
||||
import app.dapk.st.matrix.MatrixServiceInstaller
|
||||
import app.dapk.st.matrix.MatrixServiceProvider
|
||||
import app.dapk.st.matrix.ServiceDepFactory
|
||||
import app.dapk.st.matrix.*
|
||||
import app.dapk.st.matrix.common.AlgorithmName
|
||||
import app.dapk.st.matrix.common.EventId
|
||||
import app.dapk.st.matrix.common.MessageType
|
||||
|
@ -132,8 +129,8 @@ fun MatrixServiceInstaller.installMessageService(
|
|||
imageContentReader: ImageContentReader,
|
||||
messageEncrypter: ServiceDepFactory<MessageEncrypter> = ServiceDepFactory { MissingMessageEncrypter },
|
||||
mediaEncrypter: ServiceDepFactory<MediaEncrypter> = ServiceDepFactory { MissingMediaEncrypter },
|
||||
) {
|
||||
this.install { (httpClient, _, installedServices) ->
|
||||
): InstallExtender<MessageService> {
|
||||
return this.install { (httpClient, _, installedServices) ->
|
||||
SERVICE_KEY to DefaultMessageService(
|
||||
httpClient,
|
||||
localEchoStore,
|
||||
|
|
|
@ -45,6 +45,7 @@ sealed class ApiMessage {
|
|||
data class Info(
|
||||
@SerialName("h") val height: Int,
|
||||
@SerialName("w") val width: Int,
|
||||
@SerialName("mimetype") val mimeType: String,
|
||||
@SerialName("size") val size: Long,
|
||||
)
|
||||
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
package app.dapk.st.matrix.message.internal
|
||||
|
||||
import app.dapk.st.matrix.common.*
|
||||
import app.dapk.st.matrix.common.EventId
|
||||
import app.dapk.st.matrix.common.EventType
|
||||
import app.dapk.st.matrix.common.JsonString
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.http.MatrixHttpClient
|
||||
import app.dapk.st.matrix.http.MatrixHttpClient.HttpRequest
|
||||
import app.dapk.st.matrix.message.ApiSendResponse
|
||||
|
@ -57,7 +60,7 @@ internal class SendMessageUseCase(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun ApiMessageMapper.imageMessageRequest(message: Message.ImageMessage): HttpRequest<ApiSendResponse> {
|
||||
private suspend fun imageMessageRequest(message: Message.ImageMessage): HttpRequest<ApiSendResponse> {
|
||||
val imageMeta = imageContentReader.meta(message.content.uri)
|
||||
|
||||
return when (message.sendEncrypted) {
|
||||
|
@ -91,11 +94,11 @@ internal class SendMessageUseCase(
|
|||
info = ApiMessage.ImageMessage.ImageContent.Info(
|
||||
height = imageMeta.height,
|
||||
width = imageMeta.width,
|
||||
size = imageMeta.size
|
||||
size = imageMeta.size,
|
||||
mimeType = imageMeta.mimeType,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
val json = JsonString(
|
||||
MatrixHttpClient.jsonWithDefaults.encodeToString(
|
||||
ApiMessage.ImageMessage.serializer(),
|
||||
|
@ -134,7 +137,8 @@ internal class SendMessageUseCase(
|
|||
ApiMessage.ImageMessage.ImageContent.Info(
|
||||
height = imageMeta.height,
|
||||
width = imageMeta.width,
|
||||
size = imageMeta.size
|
||||
size = imageMeta.size,
|
||||
mimeType = imageMeta.mimeType,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
@ -163,14 +167,4 @@ class ApiMessageMapper {
|
|||
)
|
||||
)
|
||||
|
||||
fun Message.ImageMessage.toContents(uri: MxUrl, image: ImageContentReader.ImageContent) = ApiMessage.ImageMessage.ImageContent(
|
||||
url = uri,
|
||||
filename = image.fileName,
|
||||
ApiMessage.ImageMessage.ImageContent.Info(
|
||||
height = image.height,
|
||||
width = image.width,
|
||||
size = image.size
|
||||
)
|
||||
)
|
||||
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package app.dapk.st.matrix.room
|
||||
|
||||
import app.dapk.st.core.SingletonFlows
|
||||
import app.dapk.st.matrix.InstallExtender
|
||||
import app.dapk.st.matrix.MatrixService
|
||||
import app.dapk.st.matrix.MatrixServiceInstaller
|
||||
import app.dapk.st.matrix.MatrixServiceProvider
|
||||
|
@ -29,8 +30,8 @@ fun MatrixServiceInstaller.installProfileService(
|
|||
profileStore: ProfileStore,
|
||||
singletonFlows: SingletonFlows,
|
||||
credentialsStore: CredentialsStore,
|
||||
) {
|
||||
this.install { (httpClient, _, _, logger) ->
|
||||
): InstallExtender<ProfileService> {
|
||||
return this.install { (httpClient, _, _, logger) ->
|
||||
SERVICE_KEY to DefaultProfileService(httpClient, logger, profileStore, singletonFlows, credentialsStore)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package app.dapk.st.matrix.push
|
||||
|
||||
import app.dapk.st.matrix.InstallExtender
|
||||
import app.dapk.st.matrix.MatrixClient
|
||||
import app.dapk.st.matrix.MatrixService
|
||||
import app.dapk.st.matrix.MatrixServiceInstaller
|
||||
|
@ -38,8 +39,8 @@ interface PushService : MatrixService {
|
|||
|
||||
fun MatrixServiceInstaller.installPushService(
|
||||
credentialsStore: CredentialsStore,
|
||||
) {
|
||||
this.install { (httpClient, _, _, logger) ->
|
||||
): InstallExtender<PushService> {
|
||||
return this.install { (httpClient, _, _, logger) ->
|
||||
SERVICE_KEY to DefaultPushService(httpClient, credentialsStore, logger)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
package app.dapk.st.matrix.room
|
||||
|
||||
import app.dapk.st.matrix.MatrixService
|
||||
import app.dapk.st.matrix.MatrixServiceInstaller
|
||||
import app.dapk.st.matrix.MatrixServiceProvider
|
||||
import app.dapk.st.matrix.ServiceDepFactory
|
||||
import app.dapk.st.matrix.*
|
||||
import app.dapk.st.matrix.common.EventId
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.common.RoomMember
|
||||
|
@ -42,8 +39,8 @@ fun MatrixServiceInstaller.installRoomService(
|
|||
memberStore: MemberStore,
|
||||
roomMessenger: ServiceDepFactory<RoomMessenger>,
|
||||
roomInviteRemover: RoomInviteRemover,
|
||||
) {
|
||||
this.install { (httpClient, _, services, logger) ->
|
||||
): InstallExtender<RoomService> {
|
||||
return this.install { (httpClient, _, services, logger) ->
|
||||
SERVICE_KEY to DefaultRoomService(
|
||||
httpClient,
|
||||
logger,
|
||||
|
|
|
@ -35,6 +35,7 @@ sealed class RoomEvent {
|
|||
@SerialName("meta") override val meta: MessageMeta,
|
||||
@SerialName("encrypted_content") val encryptedContent: MegOlmV1? = null,
|
||||
@SerialName("edited") val edited: Boolean = false,
|
||||
@SerialName("redacted") val redacted: Boolean = false,
|
||||
) : RoomEvent() {
|
||||
|
||||
@Serializable
|
||||
|
|
|
@ -7,8 +7,9 @@ import kotlinx.coroutines.flow.Flow
|
|||
|
||||
interface RoomStore {
|
||||
|
||||
suspend fun persist(roomId: RoomId, state: RoomState)
|
||||
suspend fun persist(roomId: RoomId, events: List<RoomEvent>)
|
||||
suspend fun remove(rooms: List<RoomId>)
|
||||
suspend fun remove(eventId: EventId)
|
||||
suspend fun retrieve(roomId: RoomId): RoomState?
|
||||
fun latest(roomId: RoomId): Flow<RoomState>
|
||||
suspend fun insertUnread(roomId: RoomId, eventIds: List<EventId>)
|
||||
|
|
|
@ -2,10 +2,7 @@ package app.dapk.st.matrix.sync
|
|||
|
||||
import app.dapk.st.core.CoroutineDispatchers
|
||||
import app.dapk.st.core.extensions.ErrorTracker
|
||||
import app.dapk.st.matrix.MatrixClient
|
||||
import app.dapk.st.matrix.MatrixService
|
||||
import app.dapk.st.matrix.MatrixServiceInstaller
|
||||
import app.dapk.st.matrix.ServiceDepFactory
|
||||
import app.dapk.st.matrix.*
|
||||
import app.dapk.st.matrix.common.*
|
||||
import app.dapk.st.matrix.sync.internal.DefaultSyncService
|
||||
import app.dapk.st.matrix.sync.internal.request.*
|
||||
|
@ -49,7 +46,7 @@ fun MatrixServiceInstaller.installSyncService(
|
|||
errorTracker: ErrorTracker,
|
||||
coroutineDispatchers: CoroutineDispatchers,
|
||||
syncConfig: SyncConfig = SyncConfig(),
|
||||
) {
|
||||
): InstallExtender<SyncService> {
|
||||
this.serializers {
|
||||
polymorphicDefault(ApiTimelineEvent::class) {
|
||||
ApiTimelineEvent.Ignored.serializer()
|
||||
|
@ -71,7 +68,7 @@ fun MatrixServiceInstaller.installSyncService(
|
|||
}
|
||||
}
|
||||
|
||||
this.install { (httpClient, json, services, logger) ->
|
||||
return this.install { (httpClient, json, services, logger) ->
|
||||
SERVICE_KEY to DefaultSyncService(
|
||||
httpClient = httpClient,
|
||||
syncStore = syncStore,
|
||||
|
|
|
@ -82,7 +82,6 @@ internal sealed class ApiTimelineEvent {
|
|||
)
|
||||
}
|
||||
|
||||
|
||||
@Serializable
|
||||
@SerialName("m.room.member")
|
||||
internal data class RoomMember(
|
||||
|
@ -109,6 +108,15 @@ internal sealed class ApiTimelineEvent {
|
|||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@SerialName("m.room.redaction")
|
||||
internal data class RoomRedcation(
|
||||
@SerialName("event_id") val id: EventId,
|
||||
@SerialName("redacts") val redactedId: EventId,
|
||||
@SerialName("origin_server_ts") val utcTimestamp: Long,
|
||||
@SerialName("sender") val senderId: UserId,
|
||||
) : ApiTimelineEvent()
|
||||
|
||||
@Serializable
|
||||
internal data class DecryptionStatus(
|
||||
@SerialName("is_verified") val isVerified: Boolean
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
package app.dapk.st.matrix.sync.internal.sync
|
||||
|
||||
import app.dapk.st.matrix.common.MatrixLogTag
|
||||
import app.dapk.st.matrix.common.MatrixLogger
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.common.matrixLog
|
||||
import app.dapk.st.matrix.common.*
|
||||
import app.dapk.st.matrix.sync.RoomEvent
|
||||
import app.dapk.st.matrix.sync.RoomState
|
||||
import app.dapk.st.matrix.sync.RoomStore
|
||||
|
||||
|
@ -26,7 +24,7 @@ class RoomDataSource(
|
|||
logger.matrixLog(MatrixLogTag.SYNC, "no changes, not persisting")
|
||||
} else {
|
||||
roomCache[roomId] = newState
|
||||
roomStore.persist(roomId, newState)
|
||||
roomStore.persist(roomId, newState.events)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -34,4 +32,35 @@ class RoomDataSource(
|
|||
roomsLeft.forEach { roomCache.remove(it) }
|
||||
roomStore.remove(roomsLeft)
|
||||
}
|
||||
|
||||
suspend fun redact(roomId: RoomId, event: EventId) {
|
||||
val eventToRedactFromCache = roomCache[roomId]?.events?.find { it.eventId == event }
|
||||
val redactedEvent = when {
|
||||
eventToRedactFromCache != null -> {
|
||||
eventToRedactFromCache.redact().also { redacted ->
|
||||
val cachedRoomState = roomCache[roomId]
|
||||
requireNotNull(cachedRoomState)
|
||||
roomCache[roomId] = cachedRoomState.replaceEvent(eventToRedactFromCache, redacted)
|
||||
}
|
||||
}
|
||||
|
||||
else -> roomStore.findEvent(event)?.redact()
|
||||
}
|
||||
|
||||
redactedEvent?.let { roomStore.persist(roomId, listOf(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun RoomEvent.redact() = when (this) {
|
||||
is RoomEvent.Image -> RoomEvent.Message(this.eventId, this.utcTimestamp, "Redacted", this.author, this.meta, redacted = true)
|
||||
is RoomEvent.Message -> RoomEvent.Message(this.eventId, this.utcTimestamp, "Redacted", this.author, this.meta, redacted = true)
|
||||
is RoomEvent.Reply -> RoomEvent.Message(this.eventId, this.utcTimestamp, "Redacted", this.author, this.meta, redacted = true)
|
||||
}
|
||||
|
||||
private fun RoomState.replaceEvent(old: RoomEvent, new: RoomEvent): RoomState {
|
||||
val updatedEvents = this.events.toMutableList().apply {
|
||||
remove(old)
|
||||
add(new)
|
||||
}
|
||||
return this.copy(events = updatedEvents)
|
||||
}
|
|
@ -21,6 +21,10 @@ internal class RoomProcessor(
|
|||
val members = roomToProcess.apiSyncRoom.collectMembers(roomToProcess.userCredentials)
|
||||
roomMembersService.insert(roomToProcess.roomId, members)
|
||||
|
||||
roomToProcess.apiSyncRoom.timeline.apiTimelineEvents.filterIsInstance<ApiTimelineEvent.RoomRedcation>().forEach {
|
||||
roomDataSource.redact(roomToProcess.roomId, it.redactedId)
|
||||
}
|
||||
|
||||
val previousState = roomDataSource.read(roomToProcess.roomId)
|
||||
|
||||
val (newEvents, distinctEvents) = timelineEventsProcessor.process(
|
||||
|
|
|
@ -61,8 +61,6 @@ internal class SyncReducer(
|
|||
}
|
||||
}
|
||||
|
||||
roomDataSource.remove(roomsLeft)
|
||||
|
||||
return ReducerResult(
|
||||
newRooms,
|
||||
(apiRoomsToProcess + roomsWithSideEffects).awaitAll().filterNotNull(),
|
||||
|
|
|
@ -8,7 +8,6 @@ import app.dapk.st.matrix.sync.internal.SideEffectFlowIterator
|
|||
import app.dapk.st.matrix.sync.internal.overview.ReducedSyncFilterUseCase
|
||||
import app.dapk.st.matrix.sync.internal.request.syncRequest
|
||||
import app.dapk.st.matrix.sync.internal.room.SyncSideEffects
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.cancellable
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
|
@ -25,19 +24,17 @@ internal class SyncUseCase(
|
|||
private val syncConfig: SyncConfig,
|
||||
) {
|
||||
|
||||
fun sync(): Flow<Unit> {
|
||||
return flow {
|
||||
val credentials = credentialsStore.credentials()!!
|
||||
val filterId = filterUseCase.reducedFilter(credentials.userId)
|
||||
with(flowIterator) {
|
||||
loop<OverviewState>(
|
||||
initial = null,
|
||||
onPost = { emit(Unit) },
|
||||
onIteration = { onEachSyncIteration(filterId, credentials, previousState = it) }
|
||||
)
|
||||
}
|
||||
}.cancellable()
|
||||
}
|
||||
private val _flow = flow {
|
||||
val credentials = credentialsStore.credentials()!!
|
||||
val filterId = filterUseCase.reducedFilter(credentials.userId)
|
||||
with(flowIterator) {
|
||||
loop<OverviewState>(
|
||||
initial = null,
|
||||
onPost = { emit(Unit) },
|
||||
onIteration = { onEachSyncIteration(filterId, credentials, previousState = it) }
|
||||
)
|
||||
}
|
||||
}.cancellable()
|
||||
|
||||
private suspend fun onEachSyncIteration(filterId: SyncService.FilterId, credentials: UserCredentials, previousState: OverviewState?): OverviewState? {
|
||||
val syncToken = syncStore.read(key = SyncStore.SyncKey.Overview)
|
||||
|
@ -85,4 +82,6 @@ internal class SyncUseCase(
|
|||
)
|
||||
}
|
||||
|
||||
fun sync() = _flow
|
||||
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ internal class TimelineEventsProcessor(
|
|||
is ApiTimelineEvent.TimelineMessage -> event.toRoomEvent(roomToProcess.userCredentials, roomToProcess.roomId) { eventId ->
|
||||
eventLookupUseCase.lookup(eventId, decryptedTimeline, decryptedPreviousEvents)
|
||||
}
|
||||
is ApiTimelineEvent.RoomRedcation -> null
|
||||
is ApiTimelineEvent.Encryption -> null
|
||||
is ApiTimelineEvent.RoomAvatar -> null
|
||||
is ApiTimelineEvent.RoomCreate -> null
|
||||
|
|
|
@ -9,7 +9,7 @@ test {
|
|||
|
||||
dependencies {
|
||||
kotlinTest(it)
|
||||
testImplementation 'app.cash.turbine:turbine:0.10.0'
|
||||
testImplementation 'app.cash.turbine:turbine:0.11.0'
|
||||
|
||||
testImplementation Dependencies.mavenCentral.kotlinSerializationJson
|
||||
|
||||
|
@ -28,7 +28,7 @@ dependencies {
|
|||
testImplementation project(":matrix:services:crypto")
|
||||
|
||||
testImplementation rootProject.files("external/jolm.jar")
|
||||
testImplementation 'org.json:json:20220320'
|
||||
testImplementation 'org.json:json:20220924'
|
||||
|
||||
testImplementation Dependencies.mavenCentral.ktorJava
|
||||
testImplementation Dependencies.mavenCentral.sqldelightInMemory
|
||||
|
|
|
@ -123,18 +123,20 @@ class SmokeTest {
|
|||
alice.expectTextMessage(SharedState.sharedRoom, message2)
|
||||
|
||||
// Needs investigation
|
||||
// val aliceSecondDevice = testMatrix(SharedState.alice, isTemp = true, withLogging = true).also { it.newlogin() }
|
||||
// aliceSecondDevice.client.syncService().startSyncing().collectAsync {
|
||||
// val message3 = "from alice to bob and alice's second device".from(SharedState.alice.roomMember)
|
||||
// alice.sendTextMessage(SharedState.sharedRoom, message3.content, isEncrypted)
|
||||
// aliceSecondDevice.expectTextMessage(SharedState.sharedRoom, message3)
|
||||
// bob.expectTextMessage(SharedState.sharedRoom, message3)
|
||||
//
|
||||
// val message4 = "from alice's second device to bob and alice's first device".from(SharedState.alice.roomMember)
|
||||
// aliceSecondDevice.sendTextMessage(SharedState.sharedRoom, message4.content, isEncrypted)
|
||||
// alice.expectTextMessage(SharedState.sharedRoom, message4)
|
||||
// bob.expectTextMessage(SharedState.sharedRoom, message4)
|
||||
// }
|
||||
val aliceSecondDevice = testMatrix(SharedState.alice, isTemp = true, withLogging = true).also { it.newlogin() }
|
||||
aliceSecondDevice.client.syncService().startSyncing().collectAsync {
|
||||
aliceSecondDevice.client.proxyDeviceService().waitForOneTimeKeysToBeUploaded()
|
||||
|
||||
val message3 = "from alice to bob and alice's second device".from(SharedState.alice.roomMember)
|
||||
alice.sendTextMessage(SharedState.sharedRoom, message3.content, isEncrypted)
|
||||
aliceSecondDevice.expectTextMessage(SharedState.sharedRoom, message3)
|
||||
bob.expectTextMessage(SharedState.sharedRoom, message3)
|
||||
|
||||
val message4 = "from alice's second device to bob and alice's first device".from(SharedState.alice.roomMember)
|
||||
aliceSecondDevice.sendTextMessage(SharedState.sharedRoom, message4.content, isEncrypted)
|
||||
alice.expectTextMessage(SharedState.sharedRoom, message4)
|
||||
bob.expectTextMessage(SharedState.sharedRoom, message4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ import app.dapk.st.matrix.crypto.RoomMembersProvider
|
|||
import app.dapk.st.matrix.crypto.Verification
|
||||
import app.dapk.st.matrix.crypto.cryptoService
|
||||
import app.dapk.st.matrix.crypto.installCryptoService
|
||||
import app.dapk.st.matrix.device.DeviceService
|
||||
import app.dapk.st.matrix.device.deviceService
|
||||
import app.dapk.st.matrix.device.installEncryptionService
|
||||
import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory
|
||||
|
@ -39,6 +40,7 @@ import test.impl.PrintingErrorTracking
|
|||
import java.io.File
|
||||
import java.time.Clock
|
||||
import javax.imageio.ImageIO
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
object TestUsers {
|
||||
|
||||
|
@ -93,7 +95,9 @@ class TestMatrix(
|
|||
).also {
|
||||
it.install {
|
||||
installAuthService(storeModule.credentialsStore())
|
||||
installEncryptionService(storeModule.knownDevicesStore())
|
||||
installEncryptionService(storeModule.knownDevicesStore()).proxy {
|
||||
ProxyDeviceService(it)
|
||||
}
|
||||
|
||||
val olmAccountStore = OlmPersistenceWrapper(storeModule.olmStore(), base64)
|
||||
val olm = OlmWrapper(
|
||||
|
@ -355,4 +359,23 @@ class JavaImageContentReader : ImageContentReader {
|
|||
|
||||
override fun inputStream(uri: String) = File(uri).inputStream()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
class ProxyDeviceService(private val deviceService: DeviceService) : DeviceService by deviceService {
|
||||
|
||||
private var oneTimeKeysContinuation: (() -> Unit)? = null
|
||||
|
||||
override suspend fun uploadOneTimeKeys(oneTimeKeys: DeviceService.OneTimeKeys) {
|
||||
deviceService.uploadOneTimeKeys(oneTimeKeys)
|
||||
oneTimeKeysContinuation?.invoke()?.also { oneTimeKeysContinuation = null }
|
||||
}
|
||||
|
||||
suspend fun waitForOneTimeKeysToBeUploaded() {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
oneTimeKeysContinuation = { continuation.resume(Unit) }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun MatrixClient.proxyDeviceService() = this.deviceService() as ProxyDeviceService
|
|
@ -10,7 +10,7 @@
|
|||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@googleapis/androidpublisher": "^3.0.0",
|
||||
"matrix-js-sdk": "^19.4.0",
|
||||
"matrix-js-sdk": "^19.7.0",
|
||||
"request": "^2.88.2"
|
||||
}
|
||||
},
|
||||
|
@ -631,9 +631,9 @@
|
|||
"integrity": "sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA=="
|
||||
},
|
||||
"node_modules/matrix-js-sdk": {
|
||||
"version": "19.4.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-19.4.0.tgz",
|
||||
"integrity": "sha512-B8Mm4jCsCHaMaChcdM3VhZDVKrn0nMSDtYvHmS15Iu8Pe0G4qmIpk2AoADBAL9U9yN3pCqvs3TDXaQhM8UxRRA==",
|
||||
"version": "19.7.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-19.7.0.tgz",
|
||||
"integrity": "sha512-mFN1LBmEpYHCH6II1F8o7y8zJr0kn1yX7ga7tRXHbLJAlBS4bAXRsEoAzdv6OrV8/dS325JlVUYQLHFHQWjYxg==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"another-json": "^0.2.0",
|
||||
|
@ -1448,9 +1448,9 @@
|
|||
"integrity": "sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA=="
|
||||
},
|
||||
"matrix-js-sdk": {
|
||||
"version": "19.4.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-19.4.0.tgz",
|
||||
"integrity": "sha512-B8Mm4jCsCHaMaChcdM3VhZDVKrn0nMSDtYvHmS15Iu8Pe0G4qmIpk2AoADBAL9U9yN3pCqvs3TDXaQhM8UxRRA==",
|
||||
"version": "19.7.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-19.7.0.tgz",
|
||||
"integrity": "sha512-mFN1LBmEpYHCH6II1F8o7y8zJr0kn1yX7ga7tRXHbLJAlBS4bAXRsEoAzdv6OrV8/dS325JlVUYQLHFHQWjYxg==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"another-json": "^0.2.0",
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
"private": true,
|
||||
"dependencies": {
|
||||
"@googleapis/androidpublisher": "^3.0.0",
|
||||
"matrix-js-sdk": "^19.4.0",
|
||||
"matrix-js-sdk": "^19.7.0",
|
||||
"request": "^2.88.2"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,6 +56,7 @@ export const release = async (github, version, applicationId, artifacts, config)
|
|||
|
||||
console.log(releaseResult.data.id)
|
||||
|
||||
console.log("Uploading universal apk...")
|
||||
await github.rest.repos.uploadReleaseAsset({
|
||||
owner: config.owner,
|
||||
repo: config.repo,
|
||||
|
@ -64,6 +65,15 @@ export const release = async (github, version, applicationId, artifacts, config)
|
|||
data: fs.readFileSync(universalApkPath)
|
||||
})
|
||||
|
||||
console.log("Uploading foss apk...")
|
||||
await github.rest.repos.uploadReleaseAsset({
|
||||
owner: config.owner,
|
||||
repo: config.repo,
|
||||
release_id: releaseResult.data.id,
|
||||
name: `foss-signed-${version.name}.apk`,
|
||||
data: fs.readFileSync(artifacts.fossApkPath)
|
||||
})
|
||||
|
||||
console.log("Promoting beta draft release to live...")
|
||||
await promoteDraftToLive(applicationId)
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ SIGNED=$WORKING_DIR/app-foss-release-signed.apk
|
|||
ZIPALIGN=$(find "$ANDROID_HOME" -iname zipalign -print -quit)
|
||||
APKSIGNER=$(find "$ANDROID_HOME" -iname apksigner -print -quit)
|
||||
|
||||
./gradlew clean assembleRelease -Pfoss -Punsigned --no-daemon --no-configuration-cache --no-build-cache
|
||||
./gradlew assembleRelease -Pfoss -Punsigned --no-daemon --no-configuration-cache --no-build-cache
|
||||
|
||||
$ZIPALIGN -v -p 4 $UNSIGNED $ALIGNED_UNSIGNED
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
#! /bin/bash
|
||||
|
||||
./gradlew clean bundleRelease -Punsigned --no-daemon --no-configuration-cache --no-build-cache
|
||||
./gradlew bundleRelease -Punsigned --no-daemon --no-configuration-cache --no-build-cache
|
||||
|
||||
WORKING_DIR=app/build/outputs/bundle/release
|
||||
RELEASE_AAB=$WORKING_DIR/app-release.aab
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"code": 18,
|
||||
"name": "22/09/2022-V1"
|
||||
"code": 19,
|
||||
"name": "29/09/2022-V1"
|
||||
}
|
Loading…
Reference in New Issue