Merge pull request #73 from ouchadam/feature/share-images-via-small-talk

Share images via small talk
This commit is contained in:
Adam Brown 2022-08-10 19:48:51 +01:00 committed by GitHub
commit e9cbec04af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 1371 additions and 524 deletions

View File

@ -0,0 +1,29 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
</profile>
</component>

View File

@ -71,8 +71,10 @@ dependencies {
implementation project(":features:messenger") implementation project(":features:messenger")
implementation project(":features:profile") implementation project(":features:profile")
implementation project(":features:navigator") implementation project(":features:navigator")
implementation project(":features:share-entry")
implementation project(':domains:store') implementation project(':domains:store')
implementation project(":domains:android:compose-core")
implementation project(":domains:android:core") implementation project(":domains:android:core")
implementation project(":domains:android:tracking") implementation project(":domains:android:tracking")
implementation project(":domains:android:push") implementation project(":domains:android:push")

View File

@ -20,6 +20,11 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias> </activity-alias>
</application> </application>

View File

@ -19,6 +19,7 @@ import app.dapk.st.notifications.NotificationsModule
import app.dapk.st.notifications.PushAndroidService import app.dapk.st.notifications.PushAndroidService
import app.dapk.st.profile.ProfileModule import app.dapk.st.profile.ProfileModule
import app.dapk.st.settings.SettingsModule import app.dapk.st.settings.SettingsModule
import app.dapk.st.share.ShareEntryModule
import app.dapk.st.work.TaskRunnerModule import app.dapk.st.work.TaskRunnerModule
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
@ -75,6 +76,7 @@ class SmallTalkApplication : Application(), ModuleProvider {
MessengerModule::class -> featureModules.messengerModule MessengerModule::class -> featureModules.messengerModule
TaskRunnerModule::class -> appModule.domainModules.taskRunnerModule TaskRunnerModule::class -> appModule.domainModules.taskRunnerModule
CoreAndroidModule::class -> appModule.coreAndroidModule CoreAndroidModule::class -> appModule.coreAndroidModule
ShareEntryModule::class -> featureModules.shareEntryModule
else -> throw IllegalArgumentException("Unknown: $klass") else -> throw IllegalArgumentException("Unknown: $klass")
} as T } as T
} }

View File

@ -2,8 +2,11 @@ package app.dapk.st.graph
import android.app.Application import android.app.Application
import android.app.PendingIntent import android.app.PendingIntent
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build import android.os.Build
import app.dapk.db.DapkDb import app.dapk.db.DapkDb
import app.dapk.st.BuildConfig import app.dapk.st.BuildConfig
@ -33,6 +36,7 @@ import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory
import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageEncrypter
import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.message.MessageService
import app.dapk.st.matrix.message.installMessageService import app.dapk.st.matrix.message.installMessageService
import app.dapk.st.matrix.message.internal.ImageContentReader
import app.dapk.st.matrix.message.messageService import app.dapk.st.matrix.message.messageService
import app.dapk.st.matrix.push.installPushService import app.dapk.st.matrix.push.installPushService
import app.dapk.st.matrix.push.pushService import app.dapk.st.matrix.push.pushService
@ -43,6 +47,7 @@ import app.dapk.st.matrix.sync.internal.room.MessageDecrypter
import app.dapk.st.messenger.MessengerActivity import app.dapk.st.messenger.MessengerActivity
import app.dapk.st.messenger.MessengerModule import app.dapk.st.messenger.MessengerModule
import app.dapk.st.navigator.IntentFactory import app.dapk.st.navigator.IntentFactory
import app.dapk.st.navigator.MessageAttachment
import app.dapk.st.notifications.NotificationsModule import app.dapk.st.notifications.NotificationsModule
import app.dapk.st.olm.DeviceKeyFactory import app.dapk.st.olm.DeviceKeyFactory
import app.dapk.st.olm.OlmPersistenceWrapper import app.dapk.st.olm.OlmPersistenceWrapper
@ -50,6 +55,7 @@ import app.dapk.st.olm.OlmWrapper
import app.dapk.st.profile.ProfileModule import app.dapk.st.profile.ProfileModule
import app.dapk.st.push.PushModule import app.dapk.st.push.PushModule
import app.dapk.st.settings.SettingsModule import app.dapk.st.settings.SettingsModule
import app.dapk.st.share.ShareEntryModule
import app.dapk.st.tracking.TrackingModule import app.dapk.st.tracking.TrackingModule
import app.dapk.st.work.TaskRunnerModule import app.dapk.st.work.TaskRunnerModule
import app.dapk.st.work.WorkModule import app.dapk.st.work.WorkModule
@ -84,7 +90,7 @@ internal class AppModule(context: Application, logger: MatrixLogger) {
private val workModule = WorkModule(context) private val workModule = WorkModule(context)
private val imageLoaderModule = ImageLoaderModule(context) private val imageLoaderModule = ImageLoaderModule(context)
private val matrixModules = MatrixModules(storeModule, trackingModule, workModule, logger, coroutineDispatchers) private val matrixModules = MatrixModules(storeModule, trackingModule, workModule, logger, coroutineDispatchers, context.contentResolver)
val domainModules = DomainModules(matrixModules, trackingModule.errorTracker) val domainModules = DomainModules(matrixModules, trackingModule.errorTracker)
val coreAndroidModule = CoreAndroidModule(intentFactory = object : IntentFactory { val coreAndroidModule = CoreAndroidModule(intentFactory = object : IntentFactory {
@ -107,6 +113,11 @@ internal class AppModule(context: Application, logger: MatrixLogger) {
override fun home(context: Context) = Intent(context, MainActivity::class.java) override fun home(context: Context) = Intent(context, MainActivity::class.java)
override fun messenger(context: Context, roomId: RoomId) = MessengerActivity.newInstance(context, roomId) override fun messenger(context: Context, roomId: RoomId) = MessengerActivity.newInstance(context, roomId)
override fun messengerShortcut(context: Context, roomId: RoomId) = MessengerActivity.newShortcutInstance(context, roomId) override fun messengerShortcut(context: Context, roomId: RoomId) = MessengerActivity.newShortcutInstance(context, roomId)
override fun messengerAttachments(context: Context, roomId: RoomId, attachments: List<MessageAttachment>) = MessengerActivity.newMessageAttachment(
context,
roomId,
attachments
)
}) })
val featureModules = FeatureModules( val featureModules = FeatureModules(
@ -194,6 +205,10 @@ internal class FeatureModules internal constructor(
) )
} }
val shareEntryModule by unsafeLazy {
ShareEntryModule(matrixModules.sync, matrixModules.room)
}
} }
internal class MatrixModules( internal class MatrixModules(
@ -202,6 +217,7 @@ internal class MatrixModules(
private val workModule: WorkModule, private val workModule: WorkModule,
private val logger: MatrixLogger, private val logger: MatrixLogger,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
private val contentResolver: ContentResolver,
) { ) {
val matrix by unsafeLazy { val matrix by unsafeLazy {
@ -242,11 +258,13 @@ internal class MatrixModules(
base64 = base64, base64 = base64,
coroutineDispatchers = coroutineDispatchers, coroutineDispatchers = coroutineDispatchers,
) )
installMessageService(store.localEchoStore, BackgroundWorkAdapter(workModule.workScheduler())) { serviceProvider -> val imageContentReader = AndroidImageContentReader(contentResolver)
installMessageService(store.localEchoStore, BackgroundWorkAdapter(workModule.workScheduler()), imageContentReader) { serviceProvider ->
MessageEncrypter { message -> MessageEncrypter { message ->
val result = serviceProvider.cryptoService().encrypt( val result = serviceProvider.cryptoService().encrypt(
roomId = when (message) { roomId = when (message) {
is MessageService.Message.TextMessage -> message.roomId is MessageService.Message.TextMessage -> message.roomId
is MessageService.Message.ImageMessage -> message.roomId
}, },
credentials = credentialsStore.credentials()!!, credentials = credentialsStore.credentials()!!,
when (message) { when (message) {
@ -261,6 +279,8 @@ internal class MatrixModules(
) )
) )
) )
is MessageService.Message.ImageMessage -> TODO()
} }
) )
@ -331,12 +351,14 @@ internal class MatrixModules(
apiEvent.content.methods, apiEvent.content.methods,
apiEvent.content.timestampPosix, apiEvent.content.timestampPosix,
) )
is ApiToDeviceEvent.VerificationReady -> Verification.Event.Ready( is ApiToDeviceEvent.VerificationReady -> Verification.Event.Ready(
apiEvent.sender, apiEvent.sender,
apiEvent.content.fromDevice, apiEvent.content.fromDevice,
apiEvent.content.transactionId, apiEvent.content.transactionId,
apiEvent.content.methods, apiEvent.content.methods,
) )
is ApiToDeviceEvent.VerificationStart -> Verification.Event.Started( is ApiToDeviceEvent.VerificationStart -> Verification.Event.Started(
apiEvent.sender, apiEvent.sender,
apiEvent.content.fromDevice, apiEvent.content.fromDevice,
@ -347,6 +369,7 @@ internal class MatrixModules(
apiEvent.content.short, apiEvent.content.short,
apiEvent.content.transactionId, apiEvent.content.transactionId,
) )
is ApiToDeviceEvent.VerificationCancel -> TODO() is ApiToDeviceEvent.VerificationCancel -> TODO()
is ApiToDeviceEvent.VerificationAccept -> TODO() is ApiToDeviceEvent.VerificationAccept -> TODO()
is ApiToDeviceEvent.VerificationKey -> Verification.Event.Key( is ApiToDeviceEvent.VerificationKey -> Verification.Event.Key(
@ -354,6 +377,7 @@ internal class MatrixModules(
apiEvent.content.transactionId, apiEvent.content.transactionId,
apiEvent.content.key apiEvent.content.key
) )
is ApiToDeviceEvent.VerificationMac -> Verification.Event.Mac( is ApiToDeviceEvent.VerificationMac -> Verification.Event.Mac(
apiEvent.sender, apiEvent.sender,
apiEvent.content.transactionId, apiEvent.content.transactionId,
@ -374,6 +398,7 @@ internal class MatrixModules(
val roomService = services.roomService() val roomService = services.roomService()
object : RoomMembersService { object : RoomMembersService {
override suspend fun find(roomId: RoomId, userIds: List<UserId>) = roomService.findMembers(roomId, userIds) override suspend fun find(roomId: RoomId, userIds: List<UserId>) = roomService.findMembers(roomId, userIds)
override suspend fun findSummary(roomId: RoomId) = roomService.findMembersSummary(roomId)
override suspend fun insert(roomId: RoomId, members: List<RoomMember>) = roomService.insertMembers(roomId, members) override suspend fun insert(roomId: RoomId, members: List<RoomMember>) = roomService.insertMembers(roomId, members)
} }
}, },
@ -403,3 +428,25 @@ internal class DomainModules(
val pushModule by unsafeLazy { PushModule(matrixModules.push, errorTracker) } val pushModule by unsafeLazy { PushModule(matrixModules.push, errorTracker) }
val taskRunnerModule by unsafeLazy { TaskRunnerModule(TaskRunnerAdapter(matrixModules.matrix::run, AppTaskRunner(matrixModules.push))) } val taskRunnerModule by unsafeLazy { TaskRunnerModule(TaskRunnerAdapter(matrixModules.matrix::run, AppTaskRunner(matrixModules.push))) }
} }
internal class AndroidImageContentReader(private val contentResolver: ContentResolver) : ImageContentReader {
override fun read(uri: String): ImageContentReader.ImageContent {
val androidUri = Uri.parse(uri)
val fileStream = contentResolver.openInputStream(androidUri) ?: throw IllegalArgumentException("Could not process $uri")
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeStream(fileStream, null, options)
return contentResolver.openInputStream(androidUri)?.use { stream ->
val output = stream.readBytes()
ImageContentReader.ImageContent(
height = options.outHeight,
width = options.outWidth,
size = output.size.toLong(),
mimeType = options.outMimeType,
fileName = androidUri.lastPathSegment ?: "file",
content = output
)
} ?: throw IllegalArgumentException("Could not process $uri")
}
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<share-target android:targetClass="app.dapk.st.share.ShareEntryActivity">
<data android:mimeType="image/*" />
<category android:name="android.shortcut.conversation" />
</share-target>
</shortcuts>

View File

@ -0,0 +1,4 @@
package app.dapk.st.core
@JvmInline
value class AndroidUri(val value: String)

View File

@ -0,0 +1,5 @@
package app.dapk.st.core
sealed interface MimeType {
object Image: MimeType
}

View File

@ -3,5 +3,4 @@ plugins { id 'kotlin' }
dependencies { dependencies {
compileOnly project(":domains:android:stub") compileOnly project(":domains:android:stub")
implementation project(":core") implementation project(":core")
implementation project(":features:navigator")
} }

View File

@ -35,4 +35,12 @@ class MemberPersistence(
.map { Json.decodeFromString(RoomMember.serializer(), it) } .map { Json.decodeFromString(RoomMember.serializer(), it) }
} }
} }
override suspend fun query(roomId: RoomId, limit: Int): List<RoomMember> {
return coroutineDispatchers.withIoContext {
database.roomMemberQueries.selectMembersByRoom(roomId.value, limit.toLong())
.executeAsList()
.map { Json.decodeFromString(RoomMember.serializer(), it) }
}
}
} }

View File

@ -33,11 +33,13 @@ class LocalEchoPersistence(
inMemoryEchos.value = echos.groupBy { inMemoryEchos.value = echos.groupBy {
when (val message = it.message) { when (val message = it.message) {
is MessageService.Message.TextMessage -> message.roomId is MessageService.Message.TextMessage -> message.roomId
is MessageService.Message.ImageMessage -> message.roomId
} }
}.mapValues { }.mapValues {
it.value.associateBy { it.value.associateBy {
when (val message = it.message) { when (val message = it.message) {
is MessageService.Message.TextMessage -> message.localId is MessageService.Message.TextMessage -> message.localId
is MessageService.Message.ImageMessage -> message.localId
} }
} }
} }
@ -56,6 +58,7 @@ class LocalEchoPersistence(
database.transaction { database.transaction {
when (message) { when (message) {
is MessageService.Message.TextMessage -> database.localEchoQueries.delete(message.localId) is MessageService.Message.TextMessage -> database.localEchoQueries.delete(message.localId)
is MessageService.Message.ImageMessage -> database.localEchoQueries.delete(message.localId)
} }
} }
} catch (error: Exception) { } catch (error: Exception) {
@ -84,6 +87,14 @@ class LocalEchoPersistence(
Json.encodeToString(MessageService.LocalEcho.serializer(), localEcho) Json.encodeToString(MessageService.LocalEcho.serializer(), localEcho)
) )
) )
is MessageService.Message.ImageMessage -> database.localEchoQueries.insert(
DbLocalEcho(
message.localId,
message.roomId.value,
Json.encodeToString(MessageService.LocalEcho.serializer(), localEcho)
)
)
} }
} }
} }

View File

@ -10,6 +10,13 @@ SELECT blob
FROM dbRoomMember FROM dbRoomMember
WHERE room_id = ? AND user_id IN ?; WHERE room_id = ? AND user_id IN ?;
selectMembersByRoom:
SELECT blob
FROM dbRoomMember
WHERE room_id = ?
LIMIT ?;
insert: insert:
INSERT OR REPLACE INTO dbRoomMember(user_id, room_id, blob) INSERT OR REPLACE INTO dbRoomMember(user_id, room_id, blob)
VALUES (?, ?, ?); VALUES (?, ?, ?);

View File

@ -84,6 +84,7 @@ class DirectoryUseCase(
lastMessage = LastMessage( lastMessage = LastMessage(
content = when (val message = latestEcho.message) { content = when (val message = latestEcho.message) {
is MessageService.Message.TextMessage -> message.content.body is MessageService.Message.TextMessage -> message.content.body
is MessageService.Message.ImageMessage -> "\uD83D\uDCF7"
}, },
utcTimestamp = latestEcho.timestampUtc, utcTimestamp = latestEcho.timestampUtc,
author = member, author = member,

View File

@ -2,6 +2,7 @@ package app.dapk.st.directory
import android.content.Context import android.content.Context
import android.content.pm.ShortcutInfo import android.content.pm.ShortcutInfo
import androidx.core.app.Person
import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
@ -14,21 +15,26 @@ class ShortcutHandler(private val context: Context) {
fun onDirectoryUpdate(overviews: List<RoomOverview>) { fun onDirectoryUpdate(overviews: List<RoomOverview>) {
val update = overviews.map { it.roomId } val update = overviews.map { it.roomId }
if (cachedRoomIds != update) { if (cachedRoomIds != update) {
cachedRoomIds.clear() cachedRoomIds.clear()
cachedRoomIds.addAll(update) cachedRoomIds.addAll(update)
val currentShortcuts = ShortcutManagerCompat.getShortcuts(context, ShortcutManagerCompat.FLAG_MATCH_DYNAMIC)
val maxShortcutCountPerActivity = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context) val maxShortcutCountPerActivity = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context)
overviews overviews
.take(maxShortcutCountPerActivity) .take(maxShortcutCountPerActivity)
.filterNot { roomUpdate -> currentShortcuts.any { it.id == roomUpdate.roomId.value } }
.forEachIndexed { index, room -> .forEachIndexed { index, room ->
val build = ShortcutInfoCompat.Builder(context, room.roomId.value) val build = ShortcutInfoCompat.Builder(context, room.roomId.value)
.setShortLabel(room.roomName ?: "N/A") .setShortLabel(room.roomName ?: "N/A")
.setLongLabel(room.roomName ?: "N/A")
.setRank(index) .setRank(index)
.run {
this.setPerson(
Person.Builder()
.setName(room.roomName ?: "N/A")
.setKey(room.roomId.value)
.build()
)
}
.setIntent(MessengerActivity.newShortcutInstance(context, room.roomId)) .setIntent(MessengerActivity.newShortcutInstance(context, room.roomId))
.setLongLived(true) .setLongLived(true)
.setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION)) .setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))

View File

@ -8,7 +8,7 @@ import app.dapk.st.matrix.sync.RoomEvent
internal class LocalEchoMapper(private val metaMapper: MetaMapper) { internal class LocalEchoMapper(private val metaMapper: MetaMapper) {
fun MessageService.LocalEcho.toMessage(member: RoomMember): RoomEvent.Message { fun MessageService.LocalEcho.toMessage(member: RoomMember): RoomEvent {
return when (val message = this.message) { return when (val message = this.message) {
is MessageService.Message.TextMessage -> { is MessageService.Message.TextMessage -> {
RoomEvent.Message( RoomEvent.Message(
@ -19,6 +19,15 @@ internal class LocalEchoMapper(private val metaMapper: MetaMapper) {
meta = metaMapper.toMeta(this) meta = metaMapper.toMeta(this)
) )
} }
is MessageService.Message.ImageMessage -> {
RoomEvent.Image(
eventId = this.eventId ?: EventId(this.localId),
author = member,
utcTimestamp = message.timestampUtc,
meta = metaMapper.toMeta(this),
imageMeta = RoomEvent.Image.ImageMeta(100, 100, message.content.uri, null),
)
}
} }
} }

View File

@ -25,7 +25,7 @@ internal class MergeWithLocalEchosUseCaseImpl(
return roomState.copy(events = sortedEvents) return roomState.copy(events = sortedEvents)
} }
private fun uniqueEchos(echos: List<MessageService.LocalEcho>, stateByEventId: Map<EventId, RoomEvent>, member: RoomMember): List<RoomEvent.Message> { private fun uniqueEchos(echos: List<MessageService.LocalEcho>, stateByEventId: Map<EventId, RoomEvent>, member: RoomMember): List<RoomEvent> {
return with(localEventMapper) { return with(localEventMapper) {
echos echos
.filter { echo -> echo.eventId == null || stateByEventId[echo.eventId] == null } .filter { echo -> echo.eventId == null || stateByEventId[echo.eventId] == null }

View File

@ -9,11 +9,10 @@ import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import app.dapk.st.core.DapkActivity import app.dapk.st.core.*
import app.dapk.st.core.module
import app.dapk.st.core.viewModel
import app.dapk.st.design.components.SmallTalkTheme import app.dapk.st.design.components.SmallTalkTheme
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
import app.dapk.st.navigator.MessageAttachment
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
class MessengerActivity : DapkActivity() { class MessengerActivity : DapkActivity() {
@ -34,15 +33,22 @@ class MessengerActivity : DapkActivity() {
putExtra("shortcut_key", roomId.value) putExtra("shortcut_key", roomId.value)
} }
} }
fun newMessageAttachment(context: Context, roomId: RoomId, attachments: List<MessageAttachment>): Intent {
return Intent(context, MessengerActivity::class.java).apply {
putExtra("key", MessagerActivityPayload(roomId.value, attachments))
}
}
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val payload = readPayload<MessagerActivityPayload>() val payload = readPayload<MessagerActivityPayload>()
log(AppLogTag.ERROR_NON_FATAL, payload)
setContent { setContent {
SmallTalkTheme { SmallTalkTheme {
Surface(Modifier.fillMaxSize()) { Surface(Modifier.fillMaxSize()) {
MessengerScreen(RoomId(payload.roomId), viewModel, navigator) MessengerScreen(RoomId(payload.roomId), payload.attachments, viewModel, navigator)
} }
} }
} }
@ -51,7 +57,10 @@ class MessengerActivity : DapkActivity() {
@Parcelize @Parcelize
data class MessagerActivityPayload( data class MessagerActivityPayload(
val roomId: String val roomId: String,
val attachments: List<MessageAttachment>? = null
) : Parcelable ) : Parcelable
fun <T : Parcelable> Activity.readPayload(): T = intent.getParcelableExtra("key")!! fun <T : Parcelable> Activity.readPayload(): T = intent.getParcelableExtra("key") ?: intent.getStringExtra("shortcut_key")!!.let {
MessagerActivityPayload(it) as T
}

View File

@ -28,6 +28,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.* import androidx.compose.ui.unit.*
import androidx.core.net.toUri
import app.dapk.st.core.Lce import app.dapk.st.core.Lce
import app.dapk.st.core.LifecycleEffect import app.dapk.st.core.LifecycleEffect
import app.dapk.st.core.StartObserving import app.dapk.st.core.StartObserving
@ -39,18 +40,19 @@ import app.dapk.st.matrix.sync.MessageMeta
import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.RoomEvent.Message import app.dapk.st.matrix.sync.RoomEvent.Message
import app.dapk.st.matrix.sync.RoomState import app.dapk.st.matrix.sync.RoomState
import app.dapk.st.navigator.MessageAttachment
import app.dapk.st.navigator.Navigator import app.dapk.st.navigator.Navigator
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest import coil.request.ImageRequest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
internal fun MessengerScreen(roomId: RoomId, viewModel: MessengerViewModel, navigator: Navigator) { internal fun MessengerScreen(roomId: RoomId, attachments: List<MessageAttachment>?, viewModel: MessengerViewModel, navigator: Navigator) {
val state = viewModel.state val state = viewModel.state
viewModel.ObserveEvents() viewModel.ObserveEvents()
LifecycleEffect( LifecycleEffect(
onStart = { viewModel.post(MessengerAction.OnMessengerVisible(roomId)) }, onStart = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) },
onStop = { viewModel.post(MessengerAction.OnMessengerGone) } onStop = { viewModel.post(MessengerAction.OnMessengerGone) }
) )
@ -67,15 +69,23 @@ internal fun MessengerScreen(roomId: RoomId, viewModel: MessengerViewModel, navi
} }
} }
}) })
Room(state.roomState)
when (state.composerState) { when (state.composerState) {
is ComposerState.Text -> { is ComposerState.Text -> {
Composer( Room(state.roomState)
state.composerState.value, TextComposer(
state.composerState,
onTextChange = { viewModel.post(MessengerAction.ComposerTextUpdate(it)) }, onTextChange = { viewModel.post(MessengerAction.ComposerTextUpdate(it)) },
onSend = { viewModel.post(MessengerAction.ComposerSendText) }, onSend = { viewModel.post(MessengerAction.ComposerSendText) },
) )
} }
is ComposerState.Attachments -> {
AttachmentComposer(
state.composerState,
onSend = { viewModel.post(MessengerAction.ComposerSendText) },
onCancel = { viewModel.post(MessengerAction.ComposerClear) }
)
}
} }
} }
} }
@ -122,6 +132,7 @@ private fun ColumnScope.Room(roomStateLce: Lce<MessengerState>) {
} }
} }
} }
is Lce.Error -> { is Lce.Error -> {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
@ -201,6 +212,7 @@ private fun <T : RoomEvent> LazyItemScope.AlignedBubble(
} }
} }
} }
false -> { false -> {
Box(modifier = Modifier.fillParentMaxWidth(0.95f), contentAlignment = Alignment.TopStart) { Box(modifier = Modifier.fillParentMaxWidth(0.95f), contentAlignment = Alignment.TopStart) {
Bubble( Bubble(
@ -311,9 +323,11 @@ private fun Bubble(
wasPreviousMessageSameSender -> { wasPreviousMessageSameSender -> {
Spacer(modifier = Modifier.width(displayImageSize)) Spacer(modifier = Modifier.width(displayImageSize))
} }
message.author.avatarUrl == null -> { message.author.avatarUrl == null -> {
MissingAvatarIcon(message.author.displayName ?: message.author.id.value, displayImageSize) MissingAvatarIcon(message.author.displayName ?: message.author.id.value, displayImageSize)
} }
else -> { else -> {
MessengerUrlIcon(message.author.avatarUrl!!.value, displayImageSize) MessengerUrlIcon(message.author.avatarUrl!!.value, displayImageSize)
} }
@ -414,6 +428,7 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
) )
} }
is RoomEvent.Image -> { is RoomEvent.Image -> {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Image( Image(
@ -455,6 +470,7 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
) )
} }
is RoomEvent.Image -> { is RoomEvent.Image -> {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Image( Image(
@ -498,6 +514,7 @@ private fun RowScope.SendStatus(message: RoomEvent) {
MessageMeta.FromServer -> { MessageMeta.FromServer -> {
// last message is self // last message is self
} }
is MessageMeta.LocalEcho -> { is MessageMeta.LocalEcho -> {
when (val state = meta.state) { when (val state = meta.state) {
MessageMeta.LocalEcho.State.Sending, MessageMeta.LocalEcho.State.Sent -> { MessageMeta.LocalEcho.State.Sending, MessageMeta.LocalEcho.State.Sent -> {
@ -514,6 +531,7 @@ private fun RowScope.SendStatus(message: RoomEvent) {
} }
} }
} }
is MessageMeta.LocalEcho.State.Error -> { is MessageMeta.LocalEcho.State.Error -> {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Box( Box(
@ -531,7 +549,7 @@ private fun RowScope.SendStatus(message: RoomEvent) {
} }
@Composable @Composable
private fun Composer(message: String, onTextChange: (String) -> Unit, onSend: () -> Unit) { private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Unit, onSend: () -> Unit) {
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -548,12 +566,12 @@ private fun Composer(message: String, onTextChange: (String) -> Unit, onSend: ()
contentAlignment = Alignment.TopStart, contentAlignment = Alignment.TopStart,
) { ) {
Box(Modifier.padding(14.dp)) { Box(Modifier.padding(14.dp)) {
if (message.isEmpty()) { if (state.value.isEmpty()) {
Text("Message") Text("Message")
} }
BasicTextField( BasicTextField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
value = message, value = state.value,
onValueChange = { onTextChange(it) }, onValueChange = { onTextChange(it) },
cursorBrush = SolidColor(MaterialTheme.colors.primary), cursorBrush = SolidColor(MaterialTheme.colors.primary),
textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current.copy(LocalContentAlpha.current)), textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current.copy(LocalContentAlpha.current)),
@ -564,10 +582,10 @@ private fun Composer(message: String, onTextChange: (String) -> Unit, onSend: ()
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
var size by remember { mutableStateOf(IntSize(0, 0)) } var size by remember { mutableStateOf(IntSize(0, 0)) }
IconButton( IconButton(
enabled = message.isNotEmpty(), enabled = state.value.isNotEmpty(),
modifier = Modifier modifier = Modifier
.clip(CircleShape) .clip(CircleShape)
.background(if (message.isEmpty()) Color.DarkGray else MaterialTheme.colors.primary) .background(if (state.value.isEmpty()) Color.DarkGray else MaterialTheme.colors.primary)
.run { .run {
if (size.height == 0 || size.width == 0) { if (size.height == 0 || size.width == 0) {
this this
@ -591,3 +609,36 @@ private fun Composer(message: String, onTextChange: (String) -> Unit, onSend: ()
} }
} }
} }
@Composable
private fun AttachmentComposer(state: ComposerState.Attachments, onSend: () -> Unit, onCancel: () -> Unit) {
Box(modifier = Modifier.fillMaxSize()) {
val context = LocalContext.current
Image(
modifier = Modifier.fillMaxHeight().wrapContentWidth().align(Alignment.Center),
painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context)
.data(state.values.first().uri.value.toUri())
.build()
),
contentDescription = null,
)
Box(Modifier.align(Alignment.BottomEnd).padding(12.dp)) {
IconButton(
enabled = true,
modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colors.primary),
onClick = onSend,
) {
Icon(
imageVector = Icons.Filled.Send,
contentDescription = "",
tint = MaterialTheme.colors.onPrimary,
)
}
}
}
}

View File

@ -2,6 +2,7 @@ package app.dapk.st.messenger
import app.dapk.st.core.Lce import app.dapk.st.core.Lce
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
import app.dapk.st.navigator.MessageAttachment
data class MessengerScreenState( data class MessengerScreenState(
val roomId: RoomId?, val roomId: RoomId?,
@ -17,4 +18,8 @@ sealed interface ComposerState {
val value: String, val value: String,
) : ComposerState ) : ComposerState
data class Attachments(
val values: List<MessageAttachment>,
) : ComposerState
} }

View File

@ -11,6 +11,7 @@ import app.dapk.st.matrix.message.MessageService
import app.dapk.st.matrix.room.RoomService import app.dapk.st.matrix.room.RoomService
import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.RoomStore import app.dapk.st.matrix.sync.RoomStore
import app.dapk.st.navigator.MessageAttachment
import app.dapk.st.viewmodel.DapkViewModel import app.dapk.st.viewmodel.DapkViewModel
import app.dapk.st.viewmodel.MutableStateFactory import app.dapk.st.viewmodel.MutableStateFactory
import app.dapk.st.viewmodel.defaultStateFactory import app.dapk.st.viewmodel.defaultStateFactory
@ -46,18 +47,19 @@ internal class MessengerViewModel(
MessengerAction.OnMessengerGone -> syncJob?.cancel() MessengerAction.OnMessengerGone -> syncJob?.cancel()
is MessengerAction.ComposerTextUpdate -> updateState { copy(composerState = ComposerState.Text(action.newValue)) } is MessengerAction.ComposerTextUpdate -> updateState { copy(composerState = ComposerState.Text(action.newValue)) }
MessengerAction.ComposerSendText -> sendMessage() MessengerAction.ComposerSendText -> sendMessage()
MessengerAction.ComposerClear -> updateState { copy(composerState = ComposerState.Text("")) }
} }
} }
private fun start(action: MessengerAction.OnMessengerVisible) { private fun start(action: MessengerAction.OnMessengerVisible) {
updateState { copy(roomId = action.roomId) } updateState { copy(roomId = action.roomId, composerState = action.attachments?.let { ComposerState.Attachments(it) } ?: composerState) }
syncJob = viewModelScope.launch { syncJob = viewModelScope.launch {
roomStore.markRead(action.roomId) roomStore.markRead(action.roomId)
val credentials = credentialsStore.credentials()!! val credentials = credentialsStore.credentials()!!
var lastKnownReadEvent: EventId? = null var lastKnownReadEvent: EventId? = null
observeTimeline.invoke(action.roomId, credentials.userId).distinctUntilChanged().onEach { state -> observeTimeline.invoke(action.roomId, credentials.userId).distinctUntilChanged().onEach { state ->
state.lastestMessageEventFromOthers(self = credentials.userId)?.let { state.latestMessageEventFromOthers(self = credentials.userId)?.let {
if (lastKnownReadEvent != it) { if (lastKnownReadEvent != it) {
updateRoomReadStateAsync(latestReadEvent = it, state) updateRoomReadStateAsync(latestReadEvent = it, state)
lastKnownReadEvent = it lastKnownReadEvent = it
@ -98,12 +100,32 @@ internal class MessengerViewModel(
} }
} }
} }
is ComposerState.Attachments -> {
val copy = composerState.copy()
updateState { copy(composerState = ComposerState.Text("")) }
state.roomState.takeIfContent()?.let { content ->
val roomState = content.roomState
viewModelScope.launch {
messageService.scheduleMessage(
MessageService.Message.ImageMessage(
MessageService.Message.Content.ApiImageContent(uri = copy.values.first().uri.value),
roomId = roomState.roomOverview.roomId,
sendEncrypted = roomState.roomOverview.isEncrypted,
localId = localIdFactory.create(),
timestampUtc = clock.millis(),
)
)
}
}
}
} }
} }
} }
private fun MessengerState.lastestMessageEventFromOthers(self: UserId) = this.roomState.events private fun MessengerState.latestMessageEventFromOthers(self: UserId) = this.roomState.events
.filterIsInstance<RoomEvent.Message>() .filterIsInstance<RoomEvent.Message>()
.filterNot { it.author.id == self } .filterNot { it.author.id == self }
.firstOrNull() .firstOrNull()
@ -112,6 +134,7 @@ private fun MessengerState.lastestMessageEventFromOthers(self: UserId) = this.ro
sealed interface MessengerAction { sealed interface MessengerAction {
data class ComposerTextUpdate(val newValue: String) : MessengerAction data class ComposerTextUpdate(val newValue: String) : MessengerAction
object ComposerSendText : MessengerAction object ComposerSendText : MessengerAction
data class OnMessengerVisible(val roomId: RoomId) : MessengerAction object ComposerClear : MessengerAction
data class OnMessengerVisible(val roomId: RoomId, val attachments: List<MessageAttachment>?) : MessengerAction
object OnMessengerGone : MessengerAction object OnMessengerGone : MessengerAction
} }

View File

@ -36,7 +36,7 @@ class RoomSettingsActivity : DapkActivity() {
setContent { setContent {
SmallTalkTheme { SmallTalkTheme {
Surface(Modifier.fillMaxSize()) { Surface(Modifier.fillMaxSize()) {
MessengerScreen(RoomId(payload.roomId), viewModel, navigator) // MessengerScreen(RoomId(payload.roomId), payload.attachments, viewModel, navigator)
} }
} }
} }

View File

@ -71,7 +71,7 @@ class MessengerViewModelTest {
val state = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID) val state = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID)
fakeObserveTimelineUseCase.given(A_ROOM_ID, A_SELF_ID).returns(flowOf(state)) fakeObserveTimelineUseCase.given(A_ROOM_ID, A_SELF_ID).returns(flowOf(state))
viewModel.test().post(MessengerAction.OnMessengerVisible(A_ROOM_ID)) viewModel.test().post(MessengerAction.OnMessengerVisible(A_ROOM_ID, attachments = null))
assertStates<MessengerScreenState>( assertStates<MessengerScreenState>(
{ copy(roomId = A_ROOM_ID) }, { copy(roomId = A_ROOM_ID) },

View File

@ -1,4 +1,5 @@
plugins { id 'kotlin' } applyAndroidLibraryModule(project)
apply plugin: 'kotlin-parcelize'
dependencies { dependencies {
compileOnly project(":domains:android:stub") compileOnly project(":domains:android:stub")

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="app.dapk.st.navigator"/>

View File

@ -4,7 +4,13 @@ import android.app.Activity
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Parcel
import android.os.Parcelable
import app.dapk.st.core.AndroidUri
import app.dapk.st.core.MimeType
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlin.properties.ReadOnlyProperty import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
@ -30,6 +36,11 @@ interface Navigator {
activity.navigateUpTo(intentFactory.home(activity)) activity.navigateUpTo(intentFactory.home(activity))
} }
fun toMessenger(roomId: RoomId, attachments: List<MessageAttachment>) {
val intent = intentFactory.messengerAttachments(activity, roomId, attachments)
activity.startActivity(intent)
}
fun toFilePicker(requestCode: Int) { fun toFilePicker(requestCode: Int) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE) addCategory(Intent.CATEGORY_OPENABLE)
@ -46,6 +57,7 @@ interface IntentFactory {
fun home(context: Context): Intent fun home(context: Context): Intent
fun messenger(context: Context, roomId: RoomId): Intent fun messenger(context: Context, roomId: RoomId): Intent
fun messengerShortcut(context: Context, roomId: RoomId): Intent fun messengerShortcut(context: Context, roomId: RoomId): Intent
fun messengerAttachments(context: Context, roomId: RoomId, attachments: List<MessageAttachment>): Intent
} }
@ -64,3 +76,24 @@ private class DefaultNavigator(activity: Activity, intentFactory: IntentFactory)
override val navigate: Navigator.Dsl = Navigator.Dsl(activity, intentFactory) override val navigate: Navigator.Dsl = Navigator.Dsl(activity, intentFactory)
} }
@Parcelize
data class MessageAttachment(val uri: AndroidUri, val type: MimeType) : Parcelable {
private companion object : Parceler<MessageAttachment> {
override fun create(parcel: Parcel): MessageAttachment {
val uri = AndroidUri(parcel.readString()!!)
val type = when(parcel.readString()!!) {
"mimetype-image" -> MimeType.Image
else -> throw IllegalStateException()
}
return MessageAttachment(uri, type)
}
override fun MessageAttachment.write(parcel: Parcel, flags: Int) {
parcel.writeString(uri.value)
when (type) {
MimeType.Image -> parcel.writeString("mimetype-image")
}
}
}
}

View File

@ -51,7 +51,6 @@ class NotificationRendererTest {
} }
notificationRenderer.render(NotificationState(emptyMap(), removedRooms, emptySet(), emptySet())) notificationRenderer.render(NotificationState(emptyMap(), removedRooms, emptySet(), emptySet()))
verifyExpects() verifyExpects()
} }

View File

@ -0,0 +1,13 @@
applyAndroidComposeLibraryModule(project)
dependencies {
implementation project(":domains:android:compose-core")
implementation project(":domains:android:viewmodel")
implementation project(':domains:store')
implementation project(':matrix:services:sync')
implementation project(':matrix:services:room')
implementation project(':matrix:services:message')
implementation project(":core")
implementation project(":design-library")
implementation project(":features:navigator")
}

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="app.dapk.st.share">
<application>
<activity
android:name="app.dapk.st.share.ShareEntryActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
</activity>
</application>
</manifest>

View File

@ -0,0 +1,22 @@
package app.dapk.st.share
import app.dapk.st.matrix.room.RoomService
import app.dapk.st.matrix.sync.SyncService
import kotlinx.coroutines.flow.first
class FetchRoomsUseCase(
private val syncSyncService: SyncService,
private val roomService: RoomService,
) {
suspend fun bar(): List<Item> {
return syncSyncService.overview().first().map {
Item(
it.roomId,
it.roomAvatarUrl,
it.roomName ?: "",
roomService.findMembersSummary(it.roomId).map { it.displayName ?: it.id.value }
)
}
}
}

View File

@ -0,0 +1,52 @@
package app.dapk.st.share
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Surface
import androidx.compose.ui.Modifier
import app.dapk.st.core.DapkActivity
import app.dapk.st.core.module
import app.dapk.st.core.viewModel
import app.dapk.st.design.components.SmallTalkTheme
class ShareEntryActivity : DapkActivity() {
private val viewModel by viewModel { module<ShareEntryModule>().shareEntryViewModel() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val urisToShare = intent.readSendUrisOrNull() ?: throw IllegalArgumentException("Expected deeplink uris but they were missing")
setContent {
SmallTalkTheme {
Surface(Modifier.fillMaxSize()) {
ShareEntryScreen(navigator, viewModel)
}
}
}
viewModel.withUris(urisToShare)
}
}
private fun Intent.readSendUrisOrNull(): List<Uri>? {
return when (this.action) {
Intent.ACTION_SEND -> {
if (this.hasExtra(Intent.EXTRA_STREAM)) {
listOf(this.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as Uri)
} else {
null
}
}
Intent.ACTION_SEND_MULTIPLE -> {
if (this.hasExtra(Intent.EXTRA_STREAM)) {
(this.getParcelableArrayExtra(Intent.EXTRA_STREAM) as Array<Uri>).toList()
} else {
null
}
}
else -> null
}
}

View File

@ -0,0 +1,15 @@
package app.dapk.st.share
import app.dapk.st.core.ProvidableModule
import app.dapk.st.matrix.room.RoomService
import app.dapk.st.matrix.sync.SyncService
class ShareEntryModule(
private val syncService: SyncService,
private val roomService: RoomService,
) : ProvidableModule {
fun shareEntryViewModel(): ShareEntryViewModel {
return ShareEntryViewModel(FetchRoomsUseCase(syncService, roomService))
}
}

View File

@ -0,0 +1,121 @@
package app.dapk.st.share
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.dapk.st.core.LifecycleEffect
import app.dapk.st.core.MimeType
import app.dapk.st.core.StartObserving
import app.dapk.st.core.components.CenteredLoading
import app.dapk.st.design.components.CircleishAvatar
import app.dapk.st.design.components.GenericEmpty
import app.dapk.st.design.components.GenericError
import app.dapk.st.design.components.Toolbar
import app.dapk.st.navigator.MessageAttachment
import app.dapk.st.navigator.Navigator
import app.dapk.st.share.DirectoryScreenState.*
@Composable
fun ShareEntryScreen(navigator: Navigator, viewModel: ShareEntryViewModel) {
val state = viewModel.state
viewModel.ObserveEvents(navigator)
LifecycleEffect(
onStart = { viewModel.start() },
onStop = { viewModel.stop() }
)
val listState: LazyListState = rememberLazyListState(
initialFirstVisibleItemIndex = 0,
)
Box(modifier = Modifier.fillMaxSize()) {
Toolbar(title = "Send to...")
when (state) {
EmptyLoading -> CenteredLoading()
Empty -> GenericEmpty()
is Error -> GenericError {
// TODO
}
is Content -> Content(listState, state) {
viewModel.onRoomSelected(it)
}
}
}
}
@Composable
private fun ShareEntryViewModel.ObserveEvents(navigator: Navigator) {
StartObserving {
this@ObserveEvents.events.launch {
when (it) {
is DirectoryEvent.SelectRoom -> {
navigator.navigate.toMessenger(it.item.id, it.uris.map { MessageAttachment(it, MimeType.Image) })
}
}
}
}
}
@Composable
private fun Content(listState: LazyListState, state: Content, onClick: (Item) -> Unit) {
LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(top = 72.dp)) {
items(
items = state.items,
key = { it.id.value },
) {
DirectoryItem(it, onClick = onClick)
}
}
}
@Composable
private fun DirectoryItem(item: Item, onClick: (Item) -> Unit) {
val roomName = item.roomName.ifEmpty { "Empty " }
Box(
Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth()
.clickable { onClick(item) }
) {
Row(Modifier.padding(20.dp)) {
val secondaryText = MaterialTheme.colors.onBackground.copy(alpha = 0.5f)
Box(Modifier.fillMaxHeight(), contentAlignment = Alignment.Center) {
CircleishAvatar(item.roomAvatarUrl?.value, roomName, size = 50.dp)
}
Spacer(Modifier.width(20.dp))
Column {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(
modifier = Modifier.weight(1f),
maxLines = 1,
fontSize = 18.sp,
text = roomName,
overflow = TextOverflow.Ellipsis,
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colors.onBackground
)
Spacer(modifier = Modifier.width(6.dp))
}
Text(text = item.members.joinToString(), color = secondaryText, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}

View File

@ -0,0 +1,20 @@
package app.dapk.st.share
import app.dapk.st.core.AndroidUri
import app.dapk.st.matrix.common.AvatarUrl
import app.dapk.st.matrix.common.RoomId
sealed interface DirectoryScreenState {
object EmptyLoading : DirectoryScreenState
object Empty : DirectoryScreenState
data class Content(
val items: List<Item>,
) : DirectoryScreenState
}
sealed interface DirectoryEvent {
data class SelectRoom(val item: Item, val uris: List<AndroidUri>) : DirectoryEvent
}
data class Item(val id: RoomId, val roomAvatarUrl: AvatarUrl?, val roomName: String, val members: List<String>)

View File

@ -0,0 +1,43 @@
package app.dapk.st.share
import android.net.Uri
import androidx.lifecycle.viewModelScope
import app.dapk.st.core.AndroidUri
import app.dapk.st.viewmodel.DapkViewModel
import app.dapk.st.viewmodel.MutableStateFactory
import app.dapk.st.viewmodel.defaultStateFactory
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
class ShareEntryViewModel(
private val fetchRoomsUseCase: FetchRoomsUseCase,
factory: MutableStateFactory<DirectoryScreenState> = defaultStateFactory(),
) : DapkViewModel<DirectoryScreenState, DirectoryEvent>(
initialState = DirectoryScreenState.EmptyLoading,
factory,
) {
private var urisToShare: List<AndroidUri>? = null
private var syncJob: Job? = null
fun start() {
syncJob = viewModelScope.launch {
state = DirectoryScreenState.Content(fetchRoomsUseCase.bar())
}
}
fun stop() {
syncJob?.cancel()
}
fun withUris(urisToShare: List<Uri>) {
this.urisToShare = urisToShare.map { AndroidUri(it.toString()) }
}
fun onRoomSelected(item: Item) {
viewModelScope.launch {
_events.emit(DirectoryEvent.SelectRoom(item, uris = urisToShare ?: throw IllegalArgumentException("Not uris set")))
}
}
}

View File

@ -14,5 +14,6 @@ enum class EventType(val value: String) {
} }
enum class MessageType(val value: String) { enum class MessageType(val value: String) {
TEXT("m.text") TEXT("m.text"),
IMAGE("m.image"),
} }

View File

@ -1,10 +1,16 @@
package app.dapk.st.matrix.message package app.dapk.st.matrix.message
import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.MxUrl
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class ApiSendResponse( data class ApiSendResponse(
@SerialName("event_id") val eventId: EventId, @SerialName("event_id") val eventId: EventId,
)
@Serializable
data class ApiUploadResponse(
@SerialName("content_uri") val contentUri: MxUrl,
) )

View File

@ -4,11 +4,9 @@ import app.dapk.st.matrix.MatrixService
import app.dapk.st.matrix.MatrixServiceInstaller import app.dapk.st.matrix.MatrixServiceInstaller
import app.dapk.st.matrix.MatrixServiceProvider import app.dapk.st.matrix.MatrixServiceProvider
import app.dapk.st.matrix.ServiceDepFactory import app.dapk.st.matrix.ServiceDepFactory
import app.dapk.st.matrix.common.AlgorithmName import app.dapk.st.matrix.common.*
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.MessageType
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.message.internal.DefaultMessageService import app.dapk.st.matrix.message.internal.DefaultMessageService
import app.dapk.st.matrix.message.internal.ImageContentReader
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -46,6 +44,16 @@ interface MessageService : MatrixService {
@SerialName("timestamp") val timestampUtc: Long, @SerialName("timestamp") val timestampUtc: Long,
) : Message() ) : Message()
@Serializable
@SerialName("image_message")
data class ImageMessage(
@SerialName("content") val content: Content.ApiImageContent,
@SerialName("send_encrypted") val sendEncrypted: Boolean,
@SerialName("room_id") val roomId: RoomId,
@SerialName("local_id") val localId: String,
@SerialName("timestamp") val timestampUtc: Long,
) : Message()
@Serializable @Serializable
sealed class Content { sealed class Content {
@Serializable @Serializable
@ -53,6 +61,27 @@ interface MessageService : MatrixService {
@SerialName("body") val body: String, @SerialName("body") val body: String,
@SerialName("msgtype") val type: String = MessageType.TEXT.value, @SerialName("msgtype") val type: String = MessageType.TEXT.value,
) : Content() ) : Content()
@Serializable
data class ApiImageContent(
@SerialName("uri") val uri: String,
) : Content()
@Serializable
data class ImageContent(
@SerialName("url") val url: MxUrl,
@SerialName("body") val filename: String,
@SerialName("info") val info: Info,
@SerialName("msgtype") val type: String = MessageType.IMAGE.value,
) : Content() {
@Serializable
data class Info(
@SerialName("h") val height: Int,
@SerialName("w") val width: Int,
@SerialName("size") val size: Long,
)
}
} }
} }
@ -66,16 +95,19 @@ interface MessageService : MatrixService {
@Transient @Transient
val timestampUtc = when (message) { val timestampUtc = when (message) {
is Message.TextMessage -> message.timestampUtc is Message.TextMessage -> message.timestampUtc
is Message.ImageMessage -> message.timestampUtc
} }
@Transient @Transient
val roomId = when (message) { val roomId = when (message) {
is Message.TextMessage -> message.roomId is Message.TextMessage -> message.roomId
is Message.ImageMessage -> message.roomId
} }
@Transient @Transient
val localId = when (message) { val localId = when (message) {
is Message.TextMessage -> message.localId is Message.TextMessage -> message.localId
is Message.ImageMessage -> message.localId
} }
@Serializable @Serializable
@ -108,10 +140,11 @@ interface MessageService : MatrixService {
fun MatrixServiceInstaller.installMessageService( fun MatrixServiceInstaller.installMessageService(
localEchoStore: LocalEchoStore, localEchoStore: LocalEchoStore,
backgroundScheduler: BackgroundScheduler, backgroundScheduler: BackgroundScheduler,
imageContentReader: ImageContentReader,
messageEncrypter: ServiceDepFactory<MessageEncrypter> = ServiceDepFactory { MissingMessageEncrypter }, messageEncrypter: ServiceDepFactory<MessageEncrypter> = ServiceDepFactory { MissingMessageEncrypter },
) { ) {
this.install { (httpClient, _, installedServices) -> this.install { (httpClient, _, installedServices) ->
SERVICE_KEY to DefaultMessageService(httpClient, localEchoStore, backgroundScheduler, messageEncrypter.create(installedServices)) SERVICE_KEY to DefaultMessageService(httpClient, localEchoStore, backgroundScheduler, messageEncrypter.create(installedServices), imageContentReader)
} }
} }

View File

@ -13,22 +13,27 @@ import java.net.SocketException
import java.net.UnknownHostException import java.net.UnknownHostException
private const val MATRIX_MESSAGE_TASK_TYPE = "matrix-text-message" private const val MATRIX_MESSAGE_TASK_TYPE = "matrix-text-message"
private const val MATRIX_IMAGE_MESSAGE_TASK_TYPE = "matrix-image-message"
internal class DefaultMessageService( internal class DefaultMessageService(
httpClient: MatrixHttpClient, httpClient: MatrixHttpClient,
private val localEchoStore: LocalEchoStore, private val localEchoStore: LocalEchoStore,
private val backgroundScheduler: BackgroundScheduler, private val backgroundScheduler: BackgroundScheduler,
messageEncrypter: MessageEncrypter, messageEncrypter: MessageEncrypter,
imageContentReader: ImageContentReader,
) : MessageService, MatrixTaskRunner { ) : MessageService, MatrixTaskRunner {
private val sendMessageUseCase = SendMessageUseCase(httpClient, messageEncrypter) private val sendMessageUseCase = SendMessageUseCase(httpClient, messageEncrypter, imageContentReader)
private val sendEventMessageUseCase = SendEventMessageUseCase(httpClient) private val sendEventMessageUseCase = SendEventMessageUseCase(httpClient)
override suspend fun canRun(task: MatrixTaskRunner.MatrixTask) = task.type == MATRIX_MESSAGE_TASK_TYPE override suspend fun canRun(task: MatrixTaskRunner.MatrixTask) = task.type == MATRIX_MESSAGE_TASK_TYPE || task.type == MATRIX_IMAGE_MESSAGE_TASK_TYPE
override suspend fun run(task: MatrixTaskRunner.MatrixTask): MatrixTaskRunner.TaskResult { override suspend fun run(task: MatrixTaskRunner.MatrixTask): MatrixTaskRunner.TaskResult {
require(task.type == MATRIX_MESSAGE_TASK_TYPE) val message = when(task.type) {
val message = Json.decodeFromString(MessageService.Message.TextMessage.serializer(), task.jsonPayload) MATRIX_MESSAGE_TASK_TYPE -> Json.decodeFromString(MessageService.Message.TextMessage.serializer(), task.jsonPayload)
MATRIX_IMAGE_MESSAGE_TASK_TYPE -> Json.decodeFromString(MessageService.Message.ImageMessage.serializer(), task.jsonPayload)
else -> throw IllegalStateException("Unhandled task type: ${task.type}")
}
return try { return try {
sendMessage(message) sendMessage(message)
MatrixTaskRunner.TaskResult.Success MatrixTaskRunner.TaskResult.Success
@ -50,6 +55,7 @@ internal class DefaultMessageService(
localEchoStore.markSending(message) localEchoStore.markSending(message)
val localId = when (message) { val localId = when (message) {
is MessageService.Message.TextMessage -> message.localId is MessageService.Message.TextMessage -> message.localId
is MessageService.Message.ImageMessage -> message.localId
} }
backgroundScheduler.schedule(key = localId, message.toTask()) backgroundScheduler.schedule(key = localId, message.toTask())
} }
@ -68,6 +74,11 @@ internal class DefaultMessageService(
Json.encodeToString(MessageService.Message.TextMessage.serializer(), this) Json.encodeToString(MessageService.Message.TextMessage.serializer(), this)
) )
} }
is MessageService.Message.ImageMessage -> BackgroundScheduler.Task(
type = MATRIX_IMAGE_MESSAGE_TASK_TYPE,
Json.encodeToString(MessageService.Message.ImageMessage.serializer(), this)
)
} }
} }

View File

@ -0,0 +1,36 @@
package app.dapk.st.matrix.message.internal
interface ImageContentReader {
fun read(uri: String): ImageContent
data class ImageContent(
val height: Int,
val width: Int,
val size: Long,
val fileName: String,
val mimeType: String,
val content: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ImageContent
if (height != other.height) return false
if (width != other.width) return false
if (size != other.size) return false
if (!content.contentEquals(other.content)) return false
return true
}
override fun hashCode(): Int {
var result = height
result = 31 * result + width
result = 31 * result + size.hashCode()
result = 31 * result + content.contentHashCode()
return result
}
}
}

View File

@ -9,6 +9,7 @@ import app.dapk.st.matrix.message.MessageService
internal class SendMessageUseCase( internal class SendMessageUseCase(
private val httpClient: MatrixHttpClient, private val httpClient: MatrixHttpClient,
private val messageEncrypter: MessageEncrypter, private val messageEncrypter: MessageEncrypter,
private val imageContentReader: ImageContentReader,
) { ) {
suspend fun sendMessage(message: MessageService.Message): EventId { suspend fun sendMessage(message: MessageService.Message): EventId {
@ -23,6 +24,7 @@ internal class SendMessageUseCase(
content = messageEncrypter.encrypt(message), content = messageEncrypter.encrypt(message),
) )
} }
false -> { false -> {
sendRequest( sendRequest(
roomId = message.roomId, roomId = message.roomId,
@ -34,6 +36,26 @@ internal class SendMessageUseCase(
} }
httpClient.execute(request).eventId httpClient.execute(request).eventId
} }
is MessageService.Message.ImageMessage -> {
val imageContent = imageContentReader.read(message.content.uri)
val uri = httpClient.execute(uploadRequest(imageContent.content, imageContent.fileName, imageContent.mimeType)).contentUri
val request = sendRequest(
roomId = message.roomId,
eventType = EventType.ROOM_MESSAGE,
txId = message.localId,
content = MessageService.Message.Content.ImageContent(
url = uri,
filename = imageContent.fileName,
MessageService.Message.Content.ImageContent.Info(
height = imageContent.height,
width = imageContent.width,
size = imageContent.size
)
),
)
httpClient.execute(request).eventId
}
} }
} }

View File

@ -6,9 +6,12 @@ import app.dapk.st.matrix.http.MatrixHttpClient
import app.dapk.st.matrix.http.MatrixHttpClient.HttpRequest.Companion.httpRequest import app.dapk.st.matrix.http.MatrixHttpClient.HttpRequest.Companion.httpRequest
import app.dapk.st.matrix.http.jsonBody import app.dapk.st.matrix.http.jsonBody
import app.dapk.st.matrix.message.ApiSendResponse import app.dapk.st.matrix.message.ApiSendResponse
import app.dapk.st.matrix.message.ApiUploadResponse
import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageEncrypter
import app.dapk.st.matrix.message.MessageService.EventMessage import app.dapk.st.matrix.message.MessageService.EventMessage
import app.dapk.st.matrix.message.MessageService.Message import app.dapk.st.matrix.message.MessageService.Message
import io.ktor.content.*
import io.ktor.http.*
import java.util.* import java.util.*
internal fun sendRequest(roomId: RoomId, eventType: EventType, txId: String, content: Message.Content) = httpRequest<ApiSendResponse>( internal fun sendRequest(roomId: RoomId, eventType: EventType, txId: String, content: Message.Content) = httpRequest<ApiSendResponse>(
@ -16,6 +19,8 @@ internal fun sendRequest(roomId: RoomId, eventType: EventType, txId: String, con
method = MatrixHttpClient.Method.PUT, method = MatrixHttpClient.Method.PUT,
body = when (content) { body = when (content) {
is Message.Content.TextContent -> jsonBody(Message.Content.TextContent.serializer(), content, MatrixHttpClient.jsonWithDefaults) is Message.Content.TextContent -> jsonBody(Message.Content.TextContent.serializer(), content, MatrixHttpClient.jsonWithDefaults)
is Message.Content.ImageContent -> jsonBody(Message.Content.ImageContent.serializer(), content, MatrixHttpClient.jsonWithDefaults)
is Message.Content.ApiImageContent -> throw IllegalArgumentException()
} }
) )
@ -33,4 +38,12 @@ internal fun sendRequest(roomId: RoomId, eventType: EventType, content: EventMes
} }
) )
internal fun uploadRequest(body: ByteArray, filename: String, contentType: String) = httpRequest<ApiUploadResponse>(
path = "_matrix/media/r0/upload/?filename=$filename",
headers = listOf("Content-Type" to contentType),
method = MatrixHttpClient.Method.POST,
body = ByteArrayContent(body, ContentType.parse(contentType)),
)
fun txId() = "local.${UUID.randomUUID()}" fun txId() = "local.${UUID.randomUUID()}"

View File

@ -21,6 +21,7 @@ interface RoomService : MatrixService {
suspend fun findMember(roomId: RoomId, userId: UserId): RoomMember? suspend fun findMember(roomId: RoomId, userId: UserId): RoomMember?
suspend fun findMembers(roomId: RoomId, userIds: List<UserId>): List<RoomMember> suspend fun findMembers(roomId: RoomId, userIds: List<UserId>): List<RoomMember>
suspend fun findMembersSummary(roomId: RoomId): List<RoomMember>
suspend fun insertMembers(roomId: RoomId, members: List<RoomMember>) suspend fun insertMembers(roomId: RoomId, members: List<RoomMember>)
suspend fun createDm(userId: UserId, encrypted: Boolean): RoomId suspend fun createDm(userId: UserId, encrypted: Boolean): RoomId
@ -50,6 +51,7 @@ fun MatrixServiceProvider.roomService(): RoomService = this.getService(key = SER
interface MemberStore { interface MemberStore {
suspend fun insert(roomId: RoomId, members: List<RoomMember>) suspend fun insert(roomId: RoomId, members: List<RoomMember>)
suspend fun query(roomId: RoomId, userIds: List<UserId>): List<RoomMember> suspend fun query(roomId: RoomId, userIds: List<UserId>): List<RoomMember>
suspend fun query(roomId: RoomId, limit: Int): List<RoomMember>
} }
interface RoomMessenger { interface RoomMessenger {

View File

@ -39,6 +39,10 @@ class DefaultRoomService(
return roomMembers.findMembers(roomId, userIds) return roomMembers.findMembers(roomId, userIds)
} }
override suspend fun findMembersSummary(roomId: RoomId): List<RoomMember> {
return roomMembers.findMembersSummary(roomId)
}
override suspend fun insertMembers(roomId: RoomId, members: List<RoomMember>) { override suspend fun insertMembers(roomId: RoomId, members: List<RoomMember>) {
roomMembers.insert(roomId, members) roomMembers.insert(roomId, members)
} }

View File

@ -36,6 +36,8 @@ class RoomMembers(private val memberStore: MemberStore, private val membersCache
} }
} }
suspend fun findMembersSummary(roomId: RoomId) = memberStore.query(roomId, limit = 8)
suspend fun insert(roomId: RoomId, members: List<RoomMember>) { suspend fun insert(roomId: RoomId, members: List<RoomMember>) {
membersCache.insert(roomId, members) membersCache.insert(roomId, members)
memberStore.insert(roomId, members) memberStore.insert(roomId, members)

View File

@ -128,6 +128,7 @@ internal object NoOpKeySharer : KeySharer {
interface RoomMembersService { interface RoomMembersService {
suspend fun find(roomId: RoomId, userIds: List<UserId>): List<RoomMember> suspend fun find(roomId: RoomId, userIds: List<UserId>): List<RoomMember>
suspend fun findSummary(roomId: RoomId): List<RoomMember>
suspend fun insert(roomId: RoomId, members: List<RoomMember>) suspend fun insert(roomId: RoomId, members: List<RoomMember>)
} }

View File

@ -0,0 +1,33 @@
package app.dapk.st.matrix.sync.internal.request
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.UserId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
sealed class ApiAccountEvent {
@Serializable
@SerialName("m.direct")
data class Direct(
@SerialName("content") val content: Map<UserId, List<RoomId>>
) : ApiAccountEvent()
@Serializable
@SerialName("m.fully_read")
data class FullyRead(
@SerialName("content") val content: Content,
) : ApiAccountEvent() {
@Serializable
data class Content(
@SerialName("event_id") val eventId: EventId,
)
}
@Serializable
object Ignored : ApiAccountEvent()
}

View File

@ -0,0 +1,35 @@
package app.dapk.st.matrix.sync.internal.request
import app.dapk.st.matrix.common.CipherText
import app.dapk.st.matrix.common.Curve25519
import app.dapk.st.matrix.common.DeviceId
import app.dapk.st.matrix.common.SessionId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable(with = EncryptedContentDeserializer::class)
internal sealed class ApiEncryptedContent {
@Serializable
data class OlmV1(
@SerialName("ciphertext") val cipherText: Map<Curve25519, CipherTextInfo>,
@SerialName("sender_key") val senderKey: Curve25519,
) : ApiEncryptedContent()
@Serializable
data class MegOlmV1(
@SerialName("ciphertext") val cipherText: CipherText,
@SerialName("device_id") val deviceId: DeviceId,
@SerialName("sender_key") val senderKey: String,
@SerialName("session_id") val sessionId: SessionId,
@SerialName("m.relates_to") val relation: ApiTimelineEvent.TimelineMessage.Relation? = null,
) : ApiEncryptedContent()
@Serializable
data class CipherTextInfo(
@SerialName("body") val body: CipherText,
@SerialName("type") val type: Int,
)
@Serializable
object Unknown : ApiEncryptedContent()
}

View File

@ -0,0 +1,41 @@
package app.dapk.st.matrix.sync.internal.request
import app.dapk.st.matrix.common.MxUrl
import app.dapk.st.matrix.common.UserId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
sealed class ApiStrippedEvent {
@Serializable
@SerialName("m.room.member")
internal data class RoomMember(
@SerialName("content") val content: Content,
@SerialName("sender") val sender: UserId,
) : ApiStrippedEvent() {
@Serializable
internal data class Content(
@SerialName("displayname") val displayName: String? = null,
@SerialName("membership") val membership: ApiTimelineEvent.RoomMember.Content.Membership? = null,
@SerialName("is_direct") val isDirect: Boolean? = null,
@SerialName("avatar_url") val avatarUrl: MxUrl? = null,
)
}
@Serializable
@SerialName("m.room.name")
internal data class RoomName(
@SerialName("content") val content: Content,
) : ApiStrippedEvent() {
@Serializable
internal data class Content(
@SerialName("name") val name: String? = null
)
}
@Serializable
object Ignored : ApiStrippedEvent()
}

View File

@ -1,16 +1,11 @@
package app.dapk.st.matrix.sync.internal.request package app.dapk.st.matrix.sync.internal.request
import app.dapk.st.matrix.common.* import app.dapk.st.matrix.common.RoomId
import kotlinx.serialization.KSerializer import app.dapk.st.matrix.common.ServerKeyCount
import app.dapk.st.matrix.common.SyncToken
import app.dapk.st.matrix.common.UserId
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@Serializable @Serializable
internal data class ApiSyncResponse( internal data class ApiSyncResponse(
@ -28,32 +23,6 @@ data class ApiAccountData(
@SerialName("events") val events: List<ApiAccountEvent> @SerialName("events") val events: List<ApiAccountEvent>
) )
@Serializable
sealed class ApiAccountEvent {
@Serializable
@SerialName("m.direct")
data class Direct(
@SerialName("content") val content: Map<UserId, List<RoomId>>
) : ApiAccountEvent()
@Serializable
@SerialName("m.fully_read")
data class FullyRead(
@SerialName("content") val content: Content,
) : ApiAccountEvent() {
@Serializable
data class Content(
@SerialName("event_id") val eventId: EventId,
)
}
@Serializable
object Ignored : ApiAccountEvent()
}
@Serializable @Serializable
internal data class DeviceLists( internal data class DeviceLists(
@SerialName("changed") val changed: List<UserId>? = null @SerialName("changed") val changed: List<UserId>? = null
@ -64,152 +33,6 @@ internal data class ToDevice(
@SerialName("events") val events: List<ApiToDeviceEvent> @SerialName("events") val events: List<ApiToDeviceEvent>
) )
@Serializable
sealed class ApiToDeviceEvent {
@Serializable
@SerialName("m.room.encrypted")
internal data class Encrypted(
@SerialName("sender") val senderId: UserId,
@SerialName("content") val content: ApiEncryptedContent,
) : ApiToDeviceEvent()
@Serializable
@SerialName("m.room_key")
data class RoomKey(
@SerialName("sender") val sender: UserId,
@SerialName("content") val content: Content,
) : ApiToDeviceEvent() {
@Serializable
data class Content(
@SerialName("room_id") val roomId: RoomId,
@SerialName("algorithm") val algorithmName: AlgorithmName,
@SerialName("session_id") val sessionId: SessionId,
@SerialName("session_key") val sessionKey: String,
@SerialName("chain_index") val chainIndex: Long,
)
}
@Serializable
@SerialName("m.key.verification.request")
data class VerificationRequest(
@SerialName("content") val content: Content,
@SerialName("sender") val sender: UserId,
) : ApiToDeviceEvent(), ApiVerificationEvent {
@Serializable
data class Content(
@SerialName("from_device") val fromDevice: DeviceId,
@SerialName("methods") val methods: List<String>,
@SerialName("timestamp") val timestampPosix: Long,
@SerialName("transaction_id") val transactionId: String,
)
}
@Serializable
@SerialName("m.key.verification.ready")
data class VerificationReady(
@SerialName("content") val content: Content,
@SerialName("sender") val sender: UserId,
) : ApiToDeviceEvent(), ApiVerificationEvent {
@Serializable
data class Content(
@SerialName("from_device") val fromDevice: DeviceId,
@SerialName("methods") val methods: List<String>,
@SerialName("transaction_id") val transactionId: String,
)
}
@Serializable
@SerialName("m.key.verification.start")
data class VerificationStart(
@SerialName("content") val content: Content,
@SerialName("sender") val sender: UserId,
) : ApiToDeviceEvent(), ApiVerificationEvent {
@Serializable
data class Content(
@SerialName("from_device") val fromDevice: DeviceId,
@SerialName("method") val method: String,
@SerialName("key_agreement_protocols") val protocols: List<String>,
@SerialName("hashes") val hashes: List<String>,
@SerialName("message_authentication_codes") val codes: List<String>,
@SerialName("short_authentication_string") val short: List<String>,
@SerialName("transaction_id") val transactionId: String,
)
}
@Serializable
@SerialName("m.key.verification.accept")
data class VerificationAccept(
@SerialName("content") val content: Content,
@SerialName("sender") val sender: UserId,
) : ApiToDeviceEvent(), ApiVerificationEvent {
@Serializable
data class Content(
@SerialName("from_device") val fromDevice: DeviceId,
@SerialName("method") val method: String,
@SerialName("key_agreement_protocol") val protocol: String,
@SerialName("hash") val hash: String,
@SerialName("message_authentication_code") val code: String,
@SerialName("short_authentication_string") val short: List<String>,
@SerialName("commitment") val commitment: String,
@SerialName("transaction_id") val transactionId: String,
)
}
@Serializable
@SerialName("m.key.verification.key")
data class VerificationKey(
@SerialName("content") val content: Content,
@SerialName("sender") val sender: UserId,
) : ApiToDeviceEvent(), ApiVerificationEvent {
@Serializable
data class Content(
@SerialName("transaction_id") val transactionId: String,
@SerialName("key") val key: String,
)
}
@Serializable
@SerialName("m.key.verification.mac")
data class VerificationMac(
@SerialName("content") val content: Content,
@SerialName("sender") val sender: UserId,
) : ApiToDeviceEvent(), ApiVerificationEvent {
@Serializable
data class Content(
@SerialName("transaction_id") val transactionId: String,
@SerialName("keys") val keys: String,
@SerialName("mac") val mac: Map<String, String>,
)
}
@Serializable
@SerialName("m.key.verification.cancel")
data class VerificationCancel(
@SerialName("content") val content: Content,
) : ApiToDeviceEvent(), ApiVerificationEvent {
@Serializable
data class Content(
@SerialName("code") val code: String,
@SerialName("reason") val reason: String,
@SerialName("transaction_id") val transactionId: String,
)
}
@Serializable
object Ignored : ApiToDeviceEvent()
sealed interface ApiVerificationEvent
}
@Serializable @Serializable
internal data class ApiSyncRooms( internal data class ApiSyncRooms(
@SerialName("join") val join: Map<RoomId, ApiSyncRoom>? = null, @SerialName("join") val join: Map<RoomId, ApiSyncRoom>? = null,
@ -227,41 +50,6 @@ internal data class ApiInviteEvents(
@SerialName("events") val events: List<ApiStrippedEvent> @SerialName("events") val events: List<ApiStrippedEvent>
) )
@Serializable
sealed class ApiStrippedEvent {
@Serializable
@SerialName("m.room.member")
internal data class RoomMember(
@SerialName("content") val content: Content,
@SerialName("sender") val sender: UserId,
) : ApiStrippedEvent() {
@Serializable
internal data class Content(
@SerialName("displayname") val displayName: String? = null,
@SerialName("membership") val membership: ApiTimelineEvent.RoomMember.Content.Membership? = null,
@SerialName("is_direct") val isDirect: Boolean? = null,
@SerialName("avatar_url") val avatarUrl: MxUrl? = null,
)
}
@Serializable
@SerialName("m.room.name")
internal data class RoomName(
@SerialName("content") val content: Content,
) : ApiStrippedEvent() {
@Serializable
internal data class Content(
@SerialName("name") val name: String? = null
)
}
@Serializable
object Ignored : ApiStrippedEvent()
}
@Serializable @Serializable
internal data class ApiSyncRoom( internal data class ApiSyncRoom(
@SerialName("timeline") val timeline: ApiSyncRoomTimeline, @SerialName("timeline") val timeline: ApiSyncRoomTimeline,
@ -317,265 +105,3 @@ internal sealed class DecryptedContent {
@Serializable @Serializable
object Ignored : DecryptedContent() object Ignored : DecryptedContent()
} }
@Serializable(with = EncryptedContentDeserializer::class)
internal sealed class ApiEncryptedContent {
@Serializable
data class OlmV1(
@SerialName("ciphertext") val cipherText: Map<Curve25519, CipherTextInfo>,
@SerialName("sender_key") val senderKey: Curve25519,
) : ApiEncryptedContent()
@Serializable
data class MegOlmV1(
@SerialName("ciphertext") val cipherText: CipherText,
@SerialName("device_id") val deviceId: DeviceId,
@SerialName("sender_key") val senderKey: String,
@SerialName("session_id") val sessionId: SessionId,
@SerialName("m.relates_to") val relation: ApiTimelineEvent.TimelineMessage.Relation? = null,
) : ApiEncryptedContent()
@Serializable
data class CipherTextInfo(
@SerialName("body") val body: CipherText,
@SerialName("type") val type: Int,
)
@Serializable
object Unknown : ApiEncryptedContent()
}
@Serializable
internal sealed class ApiTimelineEvent {
@Serializable
@SerialName("m.room.create")
internal data class RoomCreate(
@SerialName("event_id") val id: EventId,
@SerialName("origin_server_ts") val utcTimestamp: Long,
@SerialName("content") val content: Content,
) : ApiTimelineEvent() {
@Serializable
internal data class Content(
@SerialName("type") val type: String? = null
)
}
@Serializable
@SerialName("m.room.topic")
internal data class RoomTopic(
@SerialName("event_id") val id: EventId,
@SerialName("content") val content: Content,
) : ApiTimelineEvent() {
@Serializable
internal data class Content(
@SerialName("topic") val topic: String
)
}
@Serializable
@SerialName("m.room.name")
internal data class RoomName(
@SerialName("event_id") val id: EventId,
@SerialName("content") val content: Content,
) : ApiTimelineEvent() {
@Serializable
internal data class Content(
@SerialName("name") val name: String
)
}
@Serializable
@SerialName("m.room.avatar")
internal data class RoomAvatar(
@SerialName("event_id") val id: EventId,
@SerialName("content") val content: Content,
) : ApiTimelineEvent() {
@Serializable
internal data class Content(
@SerialName("url") val url: MxUrl? = null
)
}
@Serializable
@SerialName("m.room.member")
internal data class RoomMember(
@SerialName("event_id") val id: EventId,
@SerialName("content") val content: Content,
@SerialName("sender") val senderId: UserId,
) : ApiTimelineEvent() {
@Serializable
internal data class Content(
@SerialName("displayname") val displayName: String? = null,
@SerialName("membership") val membership: Membership,
@SerialName("avatar_url") val avatarUrl: MxUrl? = null,
) {
@JvmInline
@Serializable
value class Membership(val value: String) {
fun isJoin() = value == "join"
fun isInvite() = value == "invite"
fun isLeave() = value == "leave"
}
}
}
@Serializable
internal data class DecryptionStatus(
@SerialName("is_verified") val isVerified: Boolean
)
@Serializable
@SerialName("m.room.message")
internal data class TimelineMessage(
@SerialName("event_id") val id: EventId,
@SerialName("sender") val senderId: UserId,
@SerialName("content") val content: Content,
@SerialName("origin_server_ts") val utcTimestamp: Long,
@SerialName("st.decryption_status") val decryptionStatus: DecryptionStatus? = null
) : ApiTimelineEvent() {
@Serializable(with = ApiTimelineMessageContentDeserializer::class)
internal sealed interface Content {
val relation: Relation?
@Serializable
data class Text(
@SerialName("body") val body: String? = null,
@SerialName("formatted_body") val formattedBody: String? = null,
@SerialName("m.relates_to") override val relation: Relation? = null,
@SerialName("msgtype") val messageType: String = "m.text",
) : Content
@Serializable
data class Image(
@SerialName("url") val url: MxUrl? = null,
@SerialName("file") val file: File? = null,
@SerialName("info") val info: Info? = null,
@SerialName("m.relates_to") override val relation: Relation? = null,
@SerialName("msgtype") val messageType: String = "m.image",
) : Content {
@Serializable
data class File(
@SerialName("url") val url: MxUrl,
@SerialName("iv") val iv: String,
@SerialName("v") val v: String,
@SerialName("hashes") val hashes: Map<String, String>,
@SerialName("key") val key: Key,
) {
@Serializable
data class Key(
@SerialName("k") val k: String,
)
}
@Serializable
internal data class Info(
@SerialName("h") val height: Int? = null,
@SerialName("w") val width: Int? = null,
)
}
@Serializable
object Ignored : Content {
override val relation: Relation? = null
}
}
@Serializable
data class Relation(
@SerialName("m.in_reply_to") val inReplyTo: InReplyTo? = null,
@SerialName("rel_type") val relationType: String? = null,
@SerialName("event_id") val eventId: EventId? = null
)
@Serializable
data class InReplyTo(
@SerialName("event_id") val eventId: EventId
)
}
@Serializable
@SerialName("m.room.encryption")
data class Encryption(
@SerialName("content") val content: Content,
) : ApiTimelineEvent() {
@Serializable
data class Content(
@SerialName("algorithm") val algorithm: AlgorithmName,
@SerialName("rotation_period_ms") val rotationMs: Long? = null,
@SerialName("rotation_period_msgs") val rotationMessages: Long? = null,
)
}
@Serializable
@SerialName("m.room.encrypted")
internal data class Encrypted(
@SerialName("sender") val senderId: UserId,
@SerialName("content") val encryptedContent: ApiEncryptedContent,
@SerialName("event_id") val eventId: EventId,
@SerialName("origin_server_ts") val utcTimestamp: Long,
) : ApiTimelineEvent()
@Serializable
object Ignored : ApiTimelineEvent()
}
internal object EncryptedContentDeserializer : KSerializer<ApiEncryptedContent> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("encryptedContent")
override fun deserialize(decoder: Decoder): ApiEncryptedContent {
require(decoder is JsonDecoder)
val element = decoder.decodeJsonElement()
return when (val algorithm = element.jsonObject["algorithm"]?.jsonPrimitive?.content) {
"m.olm.v1.curve25519-aes-sha2" -> ApiEncryptedContent.OlmV1.serializer().deserialize(decoder)
"m.megolm.v1.aes-sha2" -> ApiEncryptedContent.MegOlmV1.serializer().deserialize(decoder)
null -> ApiEncryptedContent.Unknown
else -> throw IllegalArgumentException("Unknown algorithm : $algorithm")
}
}
override fun serialize(encoder: Encoder, value: ApiEncryptedContent) = TODO("Not yet implemented")
}
internal object ApiTimelineMessageContentDeserializer : KSerializer<ApiTimelineEvent.TimelineMessage.Content> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("messageContent")
override fun deserialize(decoder: Decoder): ApiTimelineEvent.TimelineMessage.Content {
require(decoder is JsonDecoder)
val element = decoder.decodeJsonElement()
return when (element.jsonObject["msgtype"]?.jsonPrimitive?.content) {
"m.text" -> ApiTimelineEvent.TimelineMessage.Content.Text.serializer().deserialize(decoder)
"m.image" -> ApiTimelineEvent.TimelineMessage.Content.Image.serializer().deserialize(decoder)
else -> {
println(element)
ApiTimelineEvent.TimelineMessage.Content.Ignored
}
}
}
override fun serialize(encoder: Encoder, value: ApiTimelineEvent.TimelineMessage.Content) = when (value) {
ApiTimelineEvent.TimelineMessage.Content.Ignored -> {}
is ApiTimelineEvent.TimelineMessage.Content.Image -> ApiTimelineEvent.TimelineMessage.Content.Image.serializer().serialize(encoder, value)
is ApiTimelineEvent.TimelineMessage.Content.Text -> ApiTimelineEvent.TimelineMessage.Content.Text.serializer().serialize(encoder, value)
}
}

View File

@ -0,0 +1,197 @@
package app.dapk.st.matrix.sync.internal.request
import app.dapk.st.matrix.common.AlgorithmName
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.MxUrl
import app.dapk.st.matrix.common.UserId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
internal sealed class ApiTimelineEvent {
@Serializable
@SerialName("m.room.create")
internal data class RoomCreate(
@SerialName("event_id") val id: EventId,
@SerialName("origin_server_ts") val utcTimestamp: Long,
@SerialName("content") val content: Content,
) : ApiTimelineEvent() {
@Serializable
internal data class Content(
@SerialName("type") val type: String? = null
)
}
@Serializable
@SerialName("m.room.topic")
internal data class RoomTopic(
@SerialName("event_id") val id: EventId,
@SerialName("content") val content: Content,
) : ApiTimelineEvent() {
@Serializable
internal data class Content(
@SerialName("topic") val topic: String
)
}
@Serializable
@SerialName("m.room.name")
internal data class RoomName(
@SerialName("event_id") val id: EventId,
@SerialName("content") val content: Content,
) : ApiTimelineEvent() {
@Serializable
internal data class Content(
@SerialName("name") val name: String
)
}
@Serializable
@SerialName("m.room.avatar")
internal data class RoomAvatar(
@SerialName("event_id") val id: EventId,
@SerialName("content") val content: Content,
) : ApiTimelineEvent() {
@Serializable
internal data class Content(
@SerialName("url") val url: MxUrl? = null
)
}
@Serializable
@SerialName("m.room.member")
internal data class RoomMember(
@SerialName("event_id") val id: EventId,
@SerialName("content") val content: Content,
@SerialName("sender") val senderId: UserId,
) : ApiTimelineEvent() {
@Serializable
internal data class Content(
@SerialName("displayname") val displayName: String? = null,
@SerialName("membership") val membership: Membership,
@SerialName("avatar_url") val avatarUrl: MxUrl? = null,
) {
@JvmInline
@Serializable
value class Membership(val value: String) {
fun isJoin() = value == "join"
fun isInvite() = value == "invite"
fun isLeave() = value == "leave"
}
}
}
@Serializable
internal data class DecryptionStatus(
@SerialName("is_verified") val isVerified: Boolean
)
@Serializable
@SerialName("m.room.message")
internal data class TimelineMessage(
@SerialName("event_id") val id: EventId,
@SerialName("sender") val senderId: UserId,
@SerialName("content") val content: Content,
@SerialName("origin_server_ts") val utcTimestamp: Long,
@SerialName("st.decryption_status") val decryptionStatus: DecryptionStatus? = null
) : ApiTimelineEvent() {
@Serializable(with = ApiTimelineMessageContentDeserializer::class)
internal sealed interface Content {
val relation: Relation?
@Serializable
data class Text(
@SerialName("body") val body: String? = null,
@SerialName("formatted_body") val formattedBody: String? = null,
@SerialName("m.relates_to") override val relation: Relation? = null,
@SerialName("msgtype") val messageType: String = "m.text",
) : Content
@Serializable
data class Image(
@SerialName("url") val url: MxUrl? = null,
@SerialName("file") val file: File? = null,
@SerialName("info") val info: Info? = null,
@SerialName("m.relates_to") override val relation: Relation? = null,
@SerialName("msgtype") val messageType: String = "m.image",
) : Content {
@Serializable
data class File(
@SerialName("url") val url: MxUrl,
@SerialName("iv") val iv: String,
@SerialName("v") val v: String,
@SerialName("hashes") val hashes: Map<String, String>,
@SerialName("key") val key: Key,
) {
@Serializable
data class Key(
@SerialName("k") val k: String,
)
}
@Serializable
internal data class Info(
@SerialName("h") val height: Int? = null,
@SerialName("w") val width: Int? = null,
)
}
@Serializable
object Ignored : Content {
override val relation: Relation? = null
}
}
@Serializable
data class Relation(
@SerialName("m.in_reply_to") val inReplyTo: InReplyTo? = null,
@SerialName("rel_type") val relationType: String? = null,
@SerialName("event_id") val eventId: EventId? = null
)
@Serializable
data class InReplyTo(
@SerialName("event_id") val eventId: EventId
)
}
@Serializable
@SerialName("m.room.encryption")
data class Encryption(
@SerialName("content") val content: Content,
) : ApiTimelineEvent() {
@Serializable
data class Content(
@SerialName("algorithm") val algorithm: AlgorithmName,
@SerialName("rotation_period_ms") val rotationMs: Long? = null,
@SerialName("rotation_period_msgs") val rotationMessages: Long? = null,
)
}
@Serializable
@SerialName("m.room.encrypted")
internal data class Encrypted(
@SerialName("sender") val senderId: UserId,
@SerialName("content") val encryptedContent: ApiEncryptedContent,
@SerialName("event_id") val eventId: EventId,
@SerialName("origin_server_ts") val utcTimestamp: Long,
) : ApiTimelineEvent()
@Serializable
object Ignored : ApiTimelineEvent()
}

View File

@ -0,0 +1,32 @@
package app.dapk.st.matrix.sync.internal.request
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
internal object ApiTimelineMessageContentDeserializer : KSerializer<ApiTimelineEvent.TimelineMessage.Content> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("messageContent")
override fun deserialize(decoder: Decoder): ApiTimelineEvent.TimelineMessage.Content {
require(decoder is JsonDecoder)
val element = decoder.decodeJsonElement()
return when (element.jsonObject["msgtype"]?.jsonPrimitive?.content) {
"m.text" -> ApiTimelineEvent.TimelineMessage.Content.Text.serializer().deserialize(decoder)
"m.image" -> ApiTimelineEvent.TimelineMessage.Content.Image.serializer().deserialize(decoder)
else -> ApiTimelineEvent.TimelineMessage.Content.Ignored
}
}
override fun serialize(encoder: Encoder, value: ApiTimelineEvent.TimelineMessage.Content) = when (value) {
ApiTimelineEvent.TimelineMessage.Content.Ignored -> {}
is ApiTimelineEvent.TimelineMessage.Content.Image -> ApiTimelineEvent.TimelineMessage.Content.Image.serializer().serialize(encoder, value)
is ApiTimelineEvent.TimelineMessage.Content.Text -> ApiTimelineEvent.TimelineMessage.Content.Text.serializer().serialize(encoder, value)
}
}

View File

@ -0,0 +1,151 @@
package app.dapk.st.matrix.sync.internal.request
import app.dapk.st.matrix.common.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
sealed class ApiToDeviceEvent {
@Serializable
@SerialName("m.room.encrypted")
internal data class Encrypted(
@SerialName("sender") val senderId: UserId,
@SerialName("content") val content: ApiEncryptedContent,
) : ApiToDeviceEvent()
@Serializable
@SerialName("m.room_key")
data class RoomKey(
@SerialName("sender") val sender: UserId,
@SerialName("content") val content: Content,
) : ApiToDeviceEvent() {
@Serializable
data class Content(
@SerialName("room_id") val roomId: RoomId,
@SerialName("algorithm") val algorithmName: AlgorithmName,
@SerialName("session_id") val sessionId: SessionId,
@SerialName("session_key") val sessionKey: String,
@SerialName("chain_index") val chainIndex: Long,
)
}
@Serializable
@SerialName("m.key.verification.request")
data class VerificationRequest(
@SerialName("content") val content: Content,
@SerialName("sender") val sender: UserId,
) : ApiToDeviceEvent(), ApiVerificationEvent {
@Serializable
data class Content(
@SerialName("from_device") val fromDevice: DeviceId,
@SerialName("methods") val methods: List<String>,
@SerialName("timestamp") val timestampPosix: Long,
@SerialName("transaction_id") val transactionId: String,
)
}
@Serializable
@SerialName("m.key.verification.ready")
data class VerificationReady(
@SerialName("content") val content: Content,
@SerialName("sender") val sender: UserId,
) : ApiToDeviceEvent(), ApiVerificationEvent {
@Serializable
data class Content(
@SerialName("from_device") val fromDevice: DeviceId,
@SerialName("methods") val methods: List<String>,
@SerialName("transaction_id") val transactionId: String,
)
}
@Serializable
@SerialName("m.key.verification.start")
data class VerificationStart(
@SerialName("content") val content: Content,
@SerialName("sender") val sender: UserId,
) : ApiToDeviceEvent(), ApiVerificationEvent {
@Serializable
data class Content(
@SerialName("from_device") val fromDevice: DeviceId,
@SerialName("method") val method: String,
@SerialName("key_agreement_protocols") val protocols: List<String>,
@SerialName("hashes") val hashes: List<String>,
@SerialName("message_authentication_codes") val codes: List<String>,
@SerialName("short_authentication_string") val short: List<String>,
@SerialName("transaction_id") val transactionId: String,
)
}
@Serializable
@SerialName("m.key.verification.accept")
data class VerificationAccept(
@SerialName("content") val content: Content,
@SerialName("sender") val sender: UserId,
) : ApiToDeviceEvent(), ApiVerificationEvent {
@Serializable
data class Content(
@SerialName("from_device") val fromDevice: DeviceId,
@SerialName("method") val method: String,
@SerialName("key_agreement_protocol") val protocol: String,
@SerialName("hash") val hash: String,
@SerialName("message_authentication_code") val code: String,
@SerialName("short_authentication_string") val short: List<String>,
@SerialName("commitment") val commitment: String,
@SerialName("transaction_id") val transactionId: String,
)
}
@Serializable
@SerialName("m.key.verification.key")
data class VerificationKey(
@SerialName("content") val content: Content,
@SerialName("sender") val sender: UserId,
) : ApiToDeviceEvent(), ApiVerificationEvent {
@Serializable
data class Content(
@SerialName("transaction_id") val transactionId: String,
@SerialName("key") val key: String,
)
}
@Serializable
@SerialName("m.key.verification.mac")
data class VerificationMac(
@SerialName("content") val content: Content,
@SerialName("sender") val sender: UserId,
) : ApiToDeviceEvent(), ApiVerificationEvent {
@Serializable
data class Content(
@SerialName("transaction_id") val transactionId: String,
@SerialName("keys") val keys: String,
@SerialName("mac") val mac: Map<String, String>,
)
}
@Serializable
@SerialName("m.key.verification.cancel")
data class VerificationCancel(
@SerialName("content") val content: Content,
) : ApiToDeviceEvent(), ApiVerificationEvent {
@Serializable
data class Content(
@SerialName("code") val code: String,
@SerialName("reason") val reason: String,
@SerialName("transaction_id") val transactionId: String,
)
}
@Serializable
object Ignored : ApiToDeviceEvent()
sealed interface ApiVerificationEvent
}

View File

@ -0,0 +1,29 @@
package app.dapk.st.matrix.sync.internal.request
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
internal object EncryptedContentDeserializer : KSerializer<ApiEncryptedContent> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("encryptedContent")
override fun deserialize(decoder: Decoder): ApiEncryptedContent {
require(decoder is JsonDecoder)
val element = decoder.decodeJsonElement()
return when (val algorithm = element.jsonObject["algorithm"]?.jsonPrimitive?.content) {
"m.olm.v1.curve25519-aes-sha2" -> ApiEncryptedContent.OlmV1.serializer().deserialize(decoder)
"m.megolm.v1.aes-sha2" -> ApiEncryptedContent.MegOlmV1.serializer().deserialize(decoder)
null -> ApiEncryptedContent.Unknown
else -> throw IllegalArgumentException("Unknown algorithm : $algorithm")
}
}
override fun serialize(encoder: Encoder, value: ApiEncryptedContent) = TODO("Not yet implemented")
}

View File

@ -19,6 +19,7 @@ include ':features:notifications'
include ':features:messenger' include ':features:messenger'
include ':features:navigator' include ':features:navigator'
include ':features:verification' include ':features:verification'
include ':features:share-entry'
include ':domains:android:stub' include ':domains:android:stub'
include ':domains:android:core' include ':domains:android:core'

View File

@ -22,6 +22,7 @@ import test.MatrixTestScope
import test.TestMatrix import test.TestMatrix
import test.flowTest import test.flowTest
import test.restoreLoginAndInitialSync import test.restoreLoginAndInitialSync
import java.nio.file.Paths
import java.util.* import java.util.*
private const val HTTPS_TEST_SERVER_URL = "https://localhost:8080/" private const val HTTPS_TEST_SERVER_URL = "https://localhost:8080/"
@ -73,6 +74,14 @@ class SmokeTest {
@Test @Test
@Order(6) @Order(6)
fun `can send and receive clear image messages`() = testAfterInitialSync { alice, bob ->
val testImage = loadResourceFile("test-image.png")
alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = false)
bob.expectImageMessage(SharedState.sharedRoom, testImage, SharedState.alice.roomMember)
}
@Test
@Order(7)
fun `can request and verify devices`() = testAfterInitialSync { alice, bob -> fun `can request and verify devices`() = testAfterInitialSync { alice, bob ->
alice.client.cryptoService().verificationAction(Verification.Action.Request(bob.userId(), bob.deviceId())) alice.client.cryptoService().verificationAction(Verification.Action.Request(bob.userId(), bob.deviceId()))
alice.client.cryptoService().verificationState().automaticVerification(alice).expectAsync { it == Verification.State.Done } alice.client.cryptoService().verificationState().automaticVerification(alice).expectAsync { it == Verification.State.Done }
@ -85,7 +94,7 @@ class SmokeTest {
fun `can import E2E room keys file`() = runTest { fun `can import E2E room keys file`() = runTest {
val ignoredUser = TestUser("ignored", RoomMember(UserId("ignored"), null, null), "ignored") val ignoredUser = TestUser("ignored", RoomMember(UserId("ignored"), null, null), "ignored")
val cryptoService = TestMatrix(ignoredUser, includeLogging = true).client.cryptoService() val cryptoService = TestMatrix(ignoredUser, includeLogging = true).client.cryptoService()
val stream = Thread.currentThread().contextClassLoader.getResourceAsStream("element-keys.txt")!! val stream = loadResourceStream("element-keys.txt")
val result = with(cryptoService) { val result = with(cryptoService) {
stream.importRoomKeys(password = "aaaaaa") stream.importRoomKeys(password = "aaaaaa")
@ -182,4 +191,7 @@ private fun Flow<Verification.State>.automaticVerification(testMatrix: TestMatri
// do nothing // do nothing
} }
} }
} }
private fun loadResourceStream(name: String) = Thread.currentThread().contextClassLoader.getResourceAsStream(name)!!
private fun loadResourceFile(name: String) = Paths.get(Thread.currentThread().contextClassLoader.getResource(name)!!.toURI()).toFile()

View File

@ -5,17 +5,31 @@ package test
import TestMessage import TestMessage
import TestUser import TestUser
import app.dapk.st.core.extensions.ifNull import app.dapk.st.core.extensions.ifNull
import app.dapk.st.matrix.common.MxUrl
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.RoomMember
import app.dapk.st.matrix.common.convertMxUrToUrl
import app.dapk.st.matrix.http.MatrixHttpClient
import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.message.MessageService
import app.dapk.st.matrix.message.messageService import app.dapk.st.matrix.message.messageService
import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.syncService import app.dapk.st.matrix.sync.syncService
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.util.cio.*
import io.ktor.utils.io.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.amshove.kluent.fail import org.amshove.kluent.fail
import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeEqualTo
import java.io.File
import java.math.BigInteger
import java.net.URL
import java.security.MessageDigest
import java.util.* import java.util.*
fun flowTest(block: suspend MatrixTestScope.() -> Unit) { fun flowTest(block: suspend MatrixTestScope.() -> Unit) {
@ -131,6 +145,20 @@ class MatrixTestScope(private val testScope: TestScope) {
.assert(message) .assert(message)
} }
suspend fun TestMatrix.expectImageMessage(roomId: RoomId, image: File, author: RoomMember) {
println("expecting ${image.absolutePath} from ${author.displayName}")
this.client.syncService().room(roomId)
.map {
it.events.filterIsInstance<RoomEvent.Image>().map {
println("found: ${it.imageMeta.url}")
val output = File(image.parentFile.absolutePath, "output.png")
HttpClient().request(it.imageMeta.url).bodyAsChannel().copyAndClose(output.writeChannel())
output.readBytes().md5Hash() to it.author
}.firstOrNull()
}
.assert(image.readBytes().md5Hash() to author)
}
suspend fun TestMatrix.sendTextMessage(roomId: RoomId, content: String, isEncrypted: Boolean) { suspend fun TestMatrix.sendTextMessage(roomId: RoomId, content: String, isEncrypted: Boolean) {
println("sending $content") println("sending $content")
this.client.messageService().scheduleMessage( this.client.messageService().scheduleMessage(
@ -144,6 +172,21 @@ class MatrixTestScope(private val testScope: TestScope) {
) )
} }
suspend fun TestMatrix.sendImageMessage(roomId: RoomId, file: File, isEncrypted: Boolean) {
println("sending ${file.name}")
this.client.messageService().scheduleMessage(
MessageService.Message.ImageMessage(
content = MessageService.Message.Content.ApiImageContent(
uri = file.absolutePath,
),
roomId = roomId,
sendEncrypted = isEncrypted,
localId = "local.${UUID.randomUUID()}",
timestampUtc = System.currentTimeMillis(),
)
)
}
suspend fun TestMatrix.loginWithInitialSync() { suspend fun TestMatrix.loginWithInitialSync() {
this.restoreLogin() this.restoreLogin()
client.syncService().startSyncing().collectAsync(testScope) { client.syncService().startSyncing().collectAsync(testScope) {
@ -162,5 +205,10 @@ class MatrixTestScope(private val testScope: TestScope) {
suspend fun release() { suspend fun release() {
inProgressInstances.forEach { it.release() } inProgressInstances.forEach { it.release() }
} }
}
private fun ByteArray.md5Hash(): String {
val md = MessageDigest.getInstance("MD5")
val bigInt = BigInteger(1, md.digest(this))
return String.format("%032x", bigInt)
} }

View File

@ -22,6 +22,7 @@ import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory
import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageEncrypter
import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.message.MessageService
import app.dapk.st.matrix.message.installMessageService import app.dapk.st.matrix.message.installMessageService
import app.dapk.st.matrix.message.internal.ImageContentReader
import app.dapk.st.matrix.message.messageService import app.dapk.st.matrix.message.messageService
import app.dapk.st.matrix.push.installPushService import app.dapk.st.matrix.push.installPushService
import app.dapk.st.matrix.room.RoomMessenger import app.dapk.st.matrix.room.RoomMessenger
@ -41,7 +42,9 @@ import test.impl.InMemoryDatabase
import test.impl.InMemoryPreferences import test.impl.InMemoryPreferences
import test.impl.InstantScheduler import test.impl.InstantScheduler
import test.impl.PrintingErrorTracking import test.impl.PrintingErrorTracking
import java.io.File
import java.time.Clock import java.time.Clock
import javax.imageio.ImageIO
class TestMatrix( class TestMatrix(
private val user: TestUser, private val user: TestUser,
@ -114,11 +117,12 @@ class TestMatrix(
coroutineDispatchers = coroutineDispatchers, coroutineDispatchers = coroutineDispatchers,
) )
installMessageService(storeModule.localEchoStore, InstantScheduler(it)) { serviceProvider -> installMessageService(storeModule.localEchoStore, InstantScheduler(it), JavaImageContentReader()) { serviceProvider ->
MessageEncrypter { message -> MessageEncrypter { message ->
val result = serviceProvider.cryptoService().encrypt( val result = serviceProvider.cryptoService().encrypt(
roomId = when (message) { roomId = when (message) {
is MessageService.Message.TextMessage -> message.roomId is MessageService.Message.TextMessage -> message.roomId
is MessageService.Message.ImageMessage -> message.roomId
}, },
credentials = storeModule.credentialsStore().credentials()!!, credentials = storeModule.credentialsStore().credentials()!!,
when (message) { when (message) {
@ -132,6 +136,8 @@ class TestMatrix(
) )
) )
) )
is MessageService.Message.ImageMessage -> TODO()
} }
) )
@ -198,12 +204,14 @@ class TestMatrix(
apiEvent.content.methods, apiEvent.content.methods,
apiEvent.content.timestampPosix, apiEvent.content.timestampPosix,
) )
is ApiToDeviceEvent.VerificationReady -> Verification.Event.Ready( is ApiToDeviceEvent.VerificationReady -> Verification.Event.Ready(
apiEvent.sender, apiEvent.sender,
apiEvent.content.fromDevice, apiEvent.content.fromDevice,
apiEvent.content.transactionId, apiEvent.content.transactionId,
apiEvent.content.methods, apiEvent.content.methods,
) )
is ApiToDeviceEvent.VerificationStart -> Verification.Event.Started( is ApiToDeviceEvent.VerificationStart -> Verification.Event.Started(
apiEvent.sender, apiEvent.sender,
apiEvent.content.fromDevice, apiEvent.content.fromDevice,
@ -214,6 +222,7 @@ class TestMatrix(
apiEvent.content.short, apiEvent.content.short,
apiEvent.content.transactionId, apiEvent.content.transactionId,
) )
is ApiToDeviceEvent.VerificationAccept -> Verification.Event.Accepted( is ApiToDeviceEvent.VerificationAccept -> Verification.Event.Accepted(
apiEvent.sender, apiEvent.sender,
apiEvent.content.fromDevice, apiEvent.content.fromDevice,
@ -224,12 +233,14 @@ class TestMatrix(
apiEvent.content.short, apiEvent.content.short,
apiEvent.content.transactionId, apiEvent.content.transactionId,
) )
is ApiToDeviceEvent.VerificationCancel -> TODO() is ApiToDeviceEvent.VerificationCancel -> TODO()
is ApiToDeviceEvent.VerificationKey -> Verification.Event.Key( is ApiToDeviceEvent.VerificationKey -> Verification.Event.Key(
apiEvent.sender, apiEvent.sender,
apiEvent.content.transactionId, apiEvent.content.transactionId,
apiEvent.content.key apiEvent.content.key
) )
is ApiToDeviceEvent.VerificationMac -> Verification.Event.Mac( is ApiToDeviceEvent.VerificationMac -> Verification.Event.Mac(
apiEvent.sender, apiEvent.sender,
apiEvent.content.transactionId, apiEvent.content.transactionId,
@ -250,6 +261,7 @@ class TestMatrix(
val roomService = services.roomService() val roomService = services.roomService()
object : RoomMembersService { object : RoomMembersService {
override suspend fun find(roomId: RoomId, userIds: List<UserId>) = roomService.findMembers(roomId, userIds) override suspend fun find(roomId: RoomId, userIds: List<UserId>) = roomService.findMembers(roomId, userIds)
override suspend fun findSummary(roomId: RoomId) = roomService.findMembersSummary(roomId)
override suspend fun insert(roomId: RoomId, members: List<RoomMember>) = roomService.insertMembers(roomId, members) override suspend fun insert(roomId: RoomId, members: List<RoomMember>) = roomService.insertMembers(roomId, members)
} }
}, },
@ -285,6 +297,7 @@ class TestMatrix(
suspend fun deviceId() = storeModule.credentialsStore().credentials()!!.deviceId suspend fun deviceId() = storeModule.credentialsStore().credentials()!!.deviceId
suspend fun userId() = storeModule.credentialsStore().credentials()!!.userId suspend fun userId() = storeModule.credentialsStore().credentials()!!.userId
suspend fun credentials() = storeModule.credentialsStore().credentials()!!
suspend fun release() { suspend fun release() {
coroutineDispatchers.global.waitForCancel() coroutineDispatchers.global.waitForCancel()
@ -313,4 +326,22 @@ class JavaBase64 : Base64 {
override fun decode(input: String): ByteArray { override fun decode(input: String): ByteArray {
return java.util.Base64.getDecoder().decode(input) return java.util.Base64.getDecoder().decode(input)
} }
}
class JavaImageContentReader : ImageContentReader {
override fun read(uri: String): ImageContentReader.ImageContent {
val file = File(uri)
val size = file.length()
val image = ImageIO.read(file)
return ImageContentReader.ImageContent(
height = image.height,
width = image.width,
size = size,
mimeType = "image/${file.extension}",
fileName = file.name,
content = file.readBytes()
)
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB