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:profile")
implementation project(":features:navigator")
implementation project(":features:share-entry")
implementation project(':domains:store')
implementation project(":domains:android:compose-core")
implementation project(":domains:android:core")
implementation project(":domains:android:tracking")
implementation project(":domains:android:push")

View File

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

View File

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

View File

@ -2,8 +2,11 @@ package app.dapk.st.graph
import android.app.Application
import android.app.PendingIntent
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import app.dapk.db.DapkDb
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.MessageService
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.push.installPushService
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.MessengerModule
import app.dapk.st.navigator.IntentFactory
import app.dapk.st.navigator.MessageAttachment
import app.dapk.st.notifications.NotificationsModule
import app.dapk.st.olm.DeviceKeyFactory
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.push.PushModule
import app.dapk.st.settings.SettingsModule
import app.dapk.st.share.ShareEntryModule
import app.dapk.st.tracking.TrackingModule
import app.dapk.st.work.TaskRunnerModule
import app.dapk.st.work.WorkModule
@ -84,7 +90,7 @@ internal class AppModule(context: Application, logger: MatrixLogger) {
private val workModule = WorkModule(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 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 messenger(context: Context, roomId: RoomId) = MessengerActivity.newInstance(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(
@ -194,6 +205,10 @@ internal class FeatureModules internal constructor(
)
}
val shareEntryModule by unsafeLazy {
ShareEntryModule(matrixModules.sync, matrixModules.room)
}
}
internal class MatrixModules(
@ -202,6 +217,7 @@ internal class MatrixModules(
private val workModule: WorkModule,
private val logger: MatrixLogger,
private val coroutineDispatchers: CoroutineDispatchers,
private val contentResolver: ContentResolver,
) {
val matrix by unsafeLazy {
@ -242,11 +258,13 @@ internal class MatrixModules(
base64 = base64,
coroutineDispatchers = coroutineDispatchers,
)
installMessageService(store.localEchoStore, BackgroundWorkAdapter(workModule.workScheduler())) { serviceProvider ->
val imageContentReader = AndroidImageContentReader(contentResolver)
installMessageService(store.localEchoStore, BackgroundWorkAdapter(workModule.workScheduler()), imageContentReader) { serviceProvider ->
MessageEncrypter { message ->
val result = serviceProvider.cryptoService().encrypt(
roomId = when (message) {
is MessageService.Message.TextMessage -> message.roomId
is MessageService.Message.ImageMessage -> message.roomId
},
credentials = credentialsStore.credentials()!!,
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.timestampPosix,
)
is ApiToDeviceEvent.VerificationReady -> Verification.Event.Ready(
apiEvent.sender,
apiEvent.content.fromDevice,
apiEvent.content.transactionId,
apiEvent.content.methods,
)
is ApiToDeviceEvent.VerificationStart -> Verification.Event.Started(
apiEvent.sender,
apiEvent.content.fromDevice,
@ -347,6 +369,7 @@ internal class MatrixModules(
apiEvent.content.short,
apiEvent.content.transactionId,
)
is ApiToDeviceEvent.VerificationCancel -> TODO()
is ApiToDeviceEvent.VerificationAccept -> TODO()
is ApiToDeviceEvent.VerificationKey -> Verification.Event.Key(
@ -354,6 +377,7 @@ internal class MatrixModules(
apiEvent.content.transactionId,
apiEvent.content.key
)
is ApiToDeviceEvent.VerificationMac -> Verification.Event.Mac(
apiEvent.sender,
apiEvent.content.transactionId,
@ -374,6 +398,7 @@ internal class MatrixModules(
val roomService = services.roomService()
object : RoomMembersService {
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)
}
},
@ -403,3 +428,25 @@ internal class DomainModules(
val pushModule by unsafeLazy { PushModule(matrixModules.push, errorTracker) }
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 {
compileOnly project(":domains:android:stub")
implementation project(":core")
implementation project(":features:navigator")
}

View File

@ -35,4 +35,12 @@ class MemberPersistence(
.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 {
when (val message = it.message) {
is MessageService.Message.TextMessage -> message.roomId
is MessageService.Message.ImageMessage -> message.roomId
}
}.mapValues {
it.value.associateBy {
when (val message = it.message) {
is MessageService.Message.TextMessage -> message.localId
is MessageService.Message.ImageMessage -> message.localId
}
}
}
@ -56,6 +58,7 @@ class LocalEchoPersistence(
database.transaction {
when (message) {
is MessageService.Message.TextMessage -> database.localEchoQueries.delete(message.localId)
is MessageService.Message.ImageMessage -> database.localEchoQueries.delete(message.localId)
}
}
} catch (error: Exception) {
@ -84,6 +87,14 @@ class LocalEchoPersistence(
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
WHERE room_id = ? AND user_id IN ?;
selectMembersByRoom:
SELECT blob
FROM dbRoomMember
WHERE room_id = ?
LIMIT ?;
insert:
INSERT OR REPLACE INTO dbRoomMember(user_id, room_id, blob)
VALUES (?, ?, ?);

View File

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

View File

@ -2,6 +2,7 @@ package app.dapk.st.directory
import android.content.Context
import android.content.pm.ShortcutInfo
import androidx.core.app.Person
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import app.dapk.st.matrix.common.RoomId
@ -14,21 +15,26 @@ class ShortcutHandler(private val context: Context) {
fun onDirectoryUpdate(overviews: List<RoomOverview>) {
val update = overviews.map { it.roomId }
if (cachedRoomIds != update) {
cachedRoomIds.clear()
cachedRoomIds.addAll(update)
val currentShortcuts = ShortcutManagerCompat.getShortcuts(context, ShortcutManagerCompat.FLAG_MATCH_DYNAMIC)
val maxShortcutCountPerActivity = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context)
overviews
.take(maxShortcutCountPerActivity)
.filterNot { roomUpdate -> currentShortcuts.any { it.id == roomUpdate.roomId.value } }
.forEachIndexed { index, room ->
val build = ShortcutInfoCompat.Builder(context, room.roomId.value)
.setShortLabel(room.roomName ?: "N/A")
.setLongLabel(room.roomName ?: "N/A")
.setRank(index)
.run {
this.setPerson(
Person.Builder()
.setName(room.roomName ?: "N/A")
.setKey(room.roomId.value)
.build()
)
}
.setIntent(MessengerActivity.newShortcutInstance(context, room.roomId))
.setLongLived(true)
.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) {
fun MessageService.LocalEcho.toMessage(member: RoomMember): RoomEvent.Message {
fun MessageService.LocalEcho.toMessage(member: RoomMember): RoomEvent {
return when (val message = this.message) {
is MessageService.Message.TextMessage -> {
RoomEvent.Message(
@ -19,6 +19,15 @@ internal class LocalEchoMapper(private val metaMapper: MetaMapper) {
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)
}
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) {
echos
.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.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.core.*
import app.dapk.st.design.components.SmallTalkTheme
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.navigator.MessageAttachment
import kotlinx.parcelize.Parcelize
class MessengerActivity : DapkActivity() {
@ -34,15 +33,22 @@ class MessengerActivity : DapkActivity() {
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?) {
super.onCreate(savedInstanceState)
val payload = readPayload<MessagerActivityPayload>()
log(AppLogTag.ERROR_NON_FATAL, payload)
setContent {
SmallTalkTheme {
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
data class MessagerActivityPayload(
val roomId: String
val roomId: String,
val attachments: List<MessageAttachment>? = null
) : 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.style.TextAlign
import androidx.compose.ui.unit.*
import androidx.core.net.toUri
import app.dapk.st.core.Lce
import app.dapk.st.core.LifecycleEffect
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.Message
import app.dapk.st.matrix.sync.RoomState
import app.dapk.st.navigator.MessageAttachment
import app.dapk.st.navigator.Navigator
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import kotlinx.coroutines.launch
@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
viewModel.ObserveEvents()
LifecycleEffect(
onStart = { viewModel.post(MessengerAction.OnMessengerVisible(roomId)) },
onStart = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) },
onStop = { viewModel.post(MessengerAction.OnMessengerGone) }
)
@ -67,15 +69,23 @@ internal fun MessengerScreen(roomId: RoomId, viewModel: MessengerViewModel, navi
}
}
})
Room(state.roomState)
when (state.composerState) {
is ComposerState.Text -> {
Composer(
state.composerState.value,
Room(state.roomState)
TextComposer(
state.composerState,
onTextChange = { viewModel.post(MessengerAction.ComposerTextUpdate(it)) },
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 -> {
Box(contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
@ -201,6 +212,7 @@ private fun <T : RoomEvent> LazyItemScope.AlignedBubble(
}
}
}
false -> {
Box(modifier = Modifier.fillParentMaxWidth(0.95f), contentAlignment = Alignment.TopStart) {
Bubble(
@ -311,9 +323,11 @@ private fun Bubble(
wasPreviousMessageSameSender -> {
Spacer(modifier = Modifier.width(displayImageSize))
}
message.author.avatarUrl == null -> {
MissingAvatarIcon(message.author.displayName ?: message.author.id.value, displayImageSize)
}
else -> {
MessengerUrlIcon(message.author.avatarUrl!!.value, displayImageSize)
}
@ -414,6 +428,7 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
textAlign = TextAlign.Start,
)
}
is RoomEvent.Image -> {
Spacer(modifier = Modifier.height(4.dp))
Image(
@ -455,6 +470,7 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
textAlign = TextAlign.Start,
)
}
is RoomEvent.Image -> {
Spacer(modifier = Modifier.height(4.dp))
Image(
@ -498,6 +514,7 @@ private fun RowScope.SendStatus(message: RoomEvent) {
MessageMeta.FromServer -> {
// last message is self
}
is MessageMeta.LocalEcho -> {
when (val state = meta.state) {
MessageMeta.LocalEcho.State.Sending, MessageMeta.LocalEcho.State.Sent -> {
@ -514,6 +531,7 @@ private fun RowScope.SendStatus(message: RoomEvent) {
}
}
}
is MessageMeta.LocalEcho.State.Error -> {
Spacer(modifier = Modifier.width(4.dp))
Box(
@ -531,7 +549,7 @@ private fun RowScope.SendStatus(message: RoomEvent) {
}
@Composable
private fun Composer(message: String, onTextChange: (String) -> Unit, onSend: () -> Unit) {
private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Unit, onSend: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
@ -548,12 +566,12 @@ private fun Composer(message: String, onTextChange: (String) -> Unit, onSend: ()
contentAlignment = Alignment.TopStart,
) {
Box(Modifier.padding(14.dp)) {
if (message.isEmpty()) {
if (state.value.isEmpty()) {
Text("Message")
}
BasicTextField(
modifier = Modifier.fillMaxWidth(),
value = message,
value = state.value,
onValueChange = { onTextChange(it) },
cursorBrush = SolidColor(MaterialTheme.colors.primary),
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))
var size by remember { mutableStateOf(IntSize(0, 0)) }
IconButton(
enabled = message.isNotEmpty(),
enabled = state.value.isNotEmpty(),
modifier = Modifier
.clip(CircleShape)
.background(if (message.isEmpty()) Color.DarkGray else MaterialTheme.colors.primary)
.background(if (state.value.isEmpty()) Color.DarkGray else MaterialTheme.colors.primary)
.run {
if (size.height == 0 || size.width == 0) {
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.matrix.common.RoomId
import app.dapk.st.navigator.MessageAttachment
data class MessengerScreenState(
val roomId: RoomId?,
@ -17,4 +18,8 @@ sealed interface ComposerState {
val value: String,
) : 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.sync.RoomEvent
import app.dapk.st.matrix.sync.RoomStore
import app.dapk.st.navigator.MessageAttachment
import app.dapk.st.viewmodel.DapkViewModel
import app.dapk.st.viewmodel.MutableStateFactory
import app.dapk.st.viewmodel.defaultStateFactory
@ -46,18 +47,19 @@ internal class MessengerViewModel(
MessengerAction.OnMessengerGone -> syncJob?.cancel()
is MessengerAction.ComposerTextUpdate -> updateState { copy(composerState = ComposerState.Text(action.newValue)) }
MessengerAction.ComposerSendText -> sendMessage()
MessengerAction.ComposerClear -> updateState { copy(composerState = ComposerState.Text("")) }
}
}
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 {
roomStore.markRead(action.roomId)
val credentials = credentialsStore.credentials()!!
var lastKnownReadEvent: EventId? = null
observeTimeline.invoke(action.roomId, credentials.userId).distinctUntilChanged().onEach { state ->
state.lastestMessageEventFromOthers(self = credentials.userId)?.let {
state.latestMessageEventFromOthers(self = credentials.userId)?.let {
if (lastKnownReadEvent != it) {
updateRoomReadStateAsync(latestReadEvent = it, state)
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>()
.filterNot { it.author.id == self }
.firstOrNull()
@ -112,6 +134,7 @@ private fun MessengerState.lastestMessageEventFromOthers(self: UserId) = this.ro
sealed interface MessengerAction {
data class ComposerTextUpdate(val newValue: String) : 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
}

View File

@ -36,7 +36,7 @@ class RoomSettingsActivity : DapkActivity() {
setContent {
SmallTalkTheme {
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)
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>(
{ copy(roomId = A_ROOM_ID) },

View File

@ -1,4 +1,5 @@
plugins { id 'kotlin' }
applyAndroidLibraryModule(project)
apply plugin: 'kotlin-parcelize'
dependencies {
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.content.Context
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 kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
@ -30,6 +36,11 @@ interface Navigator {
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) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
@ -46,6 +57,7 @@ interface IntentFactory {
fun home(context: Context): Intent
fun messenger(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)
}
@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()))
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) {
TEXT("m.text")
TEXT("m.text"),
IMAGE("m.image"),
}

View File

@ -1,10 +1,16 @@
package app.dapk.st.matrix.message
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.MxUrl
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ApiSendResponse(
@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.MatrixServiceProvider
import app.dapk.st.matrix.ServiceDepFactory
import app.dapk.st.matrix.common.AlgorithmName
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.MessageType
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.*
import app.dapk.st.matrix.message.internal.DefaultMessageService
import app.dapk.st.matrix.message.internal.ImageContentReader
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@ -46,6 +44,16 @@ interface MessageService : MatrixService {
@SerialName("timestamp") val timestampUtc: Long,
) : 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
sealed class Content {
@Serializable
@ -53,6 +61,27 @@ interface MessageService : MatrixService {
@SerialName("body") val body: String,
@SerialName("msgtype") val type: String = MessageType.TEXT.value,
) : 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
val timestampUtc = when (message) {
is Message.TextMessage -> message.timestampUtc
is Message.ImageMessage -> message.timestampUtc
}
@Transient
val roomId = when (message) {
is Message.TextMessage -> message.roomId
is Message.ImageMessage -> message.roomId
}
@Transient
val localId = when (message) {
is Message.TextMessage -> message.localId
is Message.ImageMessage -> message.localId
}
@Serializable
@ -108,10 +140,11 @@ interface MessageService : MatrixService {
fun MatrixServiceInstaller.installMessageService(
localEchoStore: LocalEchoStore,
backgroundScheduler: BackgroundScheduler,
imageContentReader: ImageContentReader,
messageEncrypter: ServiceDepFactory<MessageEncrypter> = ServiceDepFactory { MissingMessageEncrypter },
) {
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
private const val MATRIX_MESSAGE_TASK_TYPE = "matrix-text-message"
private const val MATRIX_IMAGE_MESSAGE_TASK_TYPE = "matrix-image-message"
internal class DefaultMessageService(
httpClient: MatrixHttpClient,
private val localEchoStore: LocalEchoStore,
private val backgroundScheduler: BackgroundScheduler,
messageEncrypter: MessageEncrypter,
imageContentReader: ImageContentReader,
) : MessageService, MatrixTaskRunner {
private val sendMessageUseCase = SendMessageUseCase(httpClient, messageEncrypter)
private val sendMessageUseCase = SendMessageUseCase(httpClient, messageEncrypter, imageContentReader)
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 {
require(task.type == MATRIX_MESSAGE_TASK_TYPE)
val message = Json.decodeFromString(MessageService.Message.TextMessage.serializer(), task.jsonPayload)
val message = when(task.type) {
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 {
sendMessage(message)
MatrixTaskRunner.TaskResult.Success
@ -50,6 +55,7 @@ internal class DefaultMessageService(
localEchoStore.markSending(message)
val localId = when (message) {
is MessageService.Message.TextMessage -> message.localId
is MessageService.Message.ImageMessage -> message.localId
}
backgroundScheduler.schedule(key = localId, message.toTask())
}
@ -68,6 +74,11 @@ internal class DefaultMessageService(
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(
private val httpClient: MatrixHttpClient,
private val messageEncrypter: MessageEncrypter,
private val imageContentReader: ImageContentReader,
) {
suspend fun sendMessage(message: MessageService.Message): EventId {
@ -23,6 +24,7 @@ internal class SendMessageUseCase(
content = messageEncrypter.encrypt(message),
)
}
false -> {
sendRequest(
roomId = message.roomId,
@ -34,6 +36,26 @@ internal class SendMessageUseCase(
}
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.jsonBody
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.MessageService.EventMessage
import app.dapk.st.matrix.message.MessageService.Message
import io.ktor.content.*
import io.ktor.http.*
import java.util.*
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,
body = when (content) {
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()}"

View File

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

View File

@ -39,6 +39,10 @@ class DefaultRoomService(
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>) {
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>) {
membersCache.insert(roomId, members)
memberStore.insert(roomId, members)

View File

@ -128,6 +128,7 @@ internal object NoOpKeySharer : KeySharer {
interface RoomMembersService {
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>)
}

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
import app.dapk.st.matrix.common.*
import kotlinx.serialization.KSerializer
import app.dapk.st.matrix.common.RoomId
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.Serializable
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@Serializable
internal data class ApiSyncResponse(
@ -28,32 +23,6 @@ data class ApiAccountData(
@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
internal data class DeviceLists(
@SerialName("changed") val changed: List<UserId>? = null
@ -64,152 +33,6 @@ internal data class ToDevice(
@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
internal data class ApiSyncRooms(
@SerialName("join") val join: Map<RoomId, ApiSyncRoom>? = null,
@ -227,41 +50,6 @@ internal data class ApiInviteEvents(
@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
internal data class ApiSyncRoom(
@SerialName("timeline") val timeline: ApiSyncRoomTimeline,
@ -317,265 +105,3 @@ internal sealed class DecryptedContent {
@Serializable
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:navigator'
include ':features:verification'
include ':features:share-entry'
include ':domains:android:stub'
include ':domains:android:core'

View File

@ -22,6 +22,7 @@ import test.MatrixTestScope
import test.TestMatrix
import test.flowTest
import test.restoreLoginAndInitialSync
import java.nio.file.Paths
import java.util.*
private const val HTTPS_TEST_SERVER_URL = "https://localhost:8080/"
@ -73,6 +74,14 @@ class SmokeTest {
@Test
@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 ->
alice.client.cryptoService().verificationAction(Verification.Action.Request(bob.userId(), bob.deviceId()))
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 {
val ignoredUser = TestUser("ignored", RoomMember(UserId("ignored"), null, null), "ignored")
val cryptoService = TestMatrix(ignoredUser, includeLogging = true).client.cryptoService()
val stream = Thread.currentThread().contextClassLoader.getResourceAsStream("element-keys.txt")!!
val stream = loadResourceStream("element-keys.txt")
val result = with(cryptoService) {
stream.importRoomKeys(password = "aaaaaa")
@ -182,4 +191,7 @@ private fun Flow<Verification.State>.automaticVerification(testMatrix: TestMatri
// 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 TestUser
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.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.sync.RoomEvent
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.flow.*
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.fail
import org.amshove.kluent.shouldBeEqualTo
import java.io.File
import java.math.BigInteger
import java.net.URL
import java.security.MessageDigest
import java.util.*
fun flowTest(block: suspend MatrixTestScope.() -> Unit) {
@ -131,6 +145,20 @@ class MatrixTestScope(private val testScope: TestScope) {
.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) {
println("sending $content")
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() {
this.restoreLogin()
client.syncService().startSyncing().collectAsync(testScope) {
@ -162,5 +205,10 @@ class MatrixTestScope(private val testScope: TestScope) {
suspend fun 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.MessageService
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.push.installPushService
import app.dapk.st.matrix.room.RoomMessenger
@ -41,7 +42,9 @@ import test.impl.InMemoryDatabase
import test.impl.InMemoryPreferences
import test.impl.InstantScheduler
import test.impl.PrintingErrorTracking
import java.io.File
import java.time.Clock
import javax.imageio.ImageIO
class TestMatrix(
private val user: TestUser,
@ -114,11 +117,12 @@ class TestMatrix(
coroutineDispatchers = coroutineDispatchers,
)
installMessageService(storeModule.localEchoStore, InstantScheduler(it)) { serviceProvider ->
installMessageService(storeModule.localEchoStore, InstantScheduler(it), JavaImageContentReader()) { serviceProvider ->
MessageEncrypter { message ->
val result = serviceProvider.cryptoService().encrypt(
roomId = when (message) {
is MessageService.Message.TextMessage -> message.roomId
is MessageService.Message.ImageMessage -> message.roomId
},
credentials = storeModule.credentialsStore().credentials()!!,
when (message) {
@ -132,6 +136,8 @@ class TestMatrix(
)
)
)
is MessageService.Message.ImageMessage -> TODO()
}
)
@ -198,12 +204,14 @@ class TestMatrix(
apiEvent.content.methods,
apiEvent.content.timestampPosix,
)
is ApiToDeviceEvent.VerificationReady -> Verification.Event.Ready(
apiEvent.sender,
apiEvent.content.fromDevice,
apiEvent.content.transactionId,
apiEvent.content.methods,
)
is ApiToDeviceEvent.VerificationStart -> Verification.Event.Started(
apiEvent.sender,
apiEvent.content.fromDevice,
@ -214,6 +222,7 @@ class TestMatrix(
apiEvent.content.short,
apiEvent.content.transactionId,
)
is ApiToDeviceEvent.VerificationAccept -> Verification.Event.Accepted(
apiEvent.sender,
apiEvent.content.fromDevice,
@ -224,12 +233,14 @@ class TestMatrix(
apiEvent.content.short,
apiEvent.content.transactionId,
)
is ApiToDeviceEvent.VerificationCancel -> TODO()
is ApiToDeviceEvent.VerificationKey -> Verification.Event.Key(
apiEvent.sender,
apiEvent.content.transactionId,
apiEvent.content.key
)
is ApiToDeviceEvent.VerificationMac -> Verification.Event.Mac(
apiEvent.sender,
apiEvent.content.transactionId,
@ -250,6 +261,7 @@ class TestMatrix(
val roomService = services.roomService()
object : RoomMembersService {
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)
}
},
@ -285,6 +297,7 @@ class TestMatrix(
suspend fun deviceId() = storeModule.credentialsStore().credentials()!!.deviceId
suspend fun userId() = storeModule.credentialsStore().credentials()!!.userId
suspend fun credentials() = storeModule.credentialsStore().credentials()!!
suspend fun release() {
coroutineDispatchers.global.waitForCancel()
@ -313,4 +326,22 @@ class JavaBase64 : Base64 {
override fun decode(input: String): ByteArray {
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