Merge pull request #169 from ouchadam/release-candidate

[Auto] Release Candidate
This commit is contained in:
Adam Brown 2022-09-29 20:08:08 +01:00 committed by GitHub
commit 0a6c4df6c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 881 additions and 171 deletions

View File

@ -34,11 +34,14 @@ jobs:
touch .secrets/service-account.json
touch .secrets/matrix.json
echo -n '${{ secrets.UPLOAD_KEY }}' | base64 --decode >> .secrets/upload-key.jks
echo -n '${{ secrets.FDROID_KEY }}' | base64 --decode >> .secrets/fdroid.keystore
echo -n '${{ secrets.SERVICE_ACCOUNT }}' | base64 --decode >> .secrets/service-account.json
echo -n '${{ secrets.MATRIX }}' | base64 --decode >> .secrets/matrix.json
- name: Assemble release variant
run: ./tools/generate-release.sh ${{ secrets.STORE_PASS }}
run: |
./tools/generate-release.sh ${{ secrets.STORE_PASS }}
./tools/generate-fdroid-release.sh ${{ secrets.FDROID_STORE_PASS }}
- uses: actions/github-script@v6
with:
@ -48,6 +51,7 @@ jobs:
const artifacts = {
bundle: '${{ github.workspace }}/app/build/outputs/bundle/release/app-release.aab',
mapping: '${{ github.workspace }}/app/build/outputs/mapping/release/mapping.txt',
fossApkPath: '${{ github.workspace }}/app/build/outputs/apk/release/app-foss-release-signed.apk',
}
await publishRelease(github, artifacts)

View File

@ -1,4 +1,4 @@
name: Nightly
name: Release Train
on:
workflow_dispatch:

View File

@ -3,6 +3,8 @@
package="app.dapk.st">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<application
android:name="app.dapk.st.SmallTalkApplication"

View File

@ -15,6 +15,7 @@ import app.dapk.st.graph.AppModule
import app.dapk.st.home.HomeModule
import app.dapk.st.login.LoginModule
import app.dapk.st.messenger.MessengerModule
import app.dapk.st.messenger.gallery.ImageGalleryModule
import app.dapk.st.notifications.NotificationsModule
import app.dapk.st.profile.ProfileModule
import app.dapk.st.push.PushModule
@ -54,7 +55,9 @@ class SmallTalkApplication : Application(), ModuleProvider {
private fun onApplicationLaunch(notificationsModule: NotificationsModule, storeModule: StoreModule) {
applicationScope.launch {
storeModule.credentialsStore().credentials()?.let {
featureModules.pushModule.pushTokenRegistrar().registerCurrentToken()
}
storeModule.localEchoStore.preload()
}
@ -79,6 +82,7 @@ class SmallTalkApplication : Application(), ModuleProvider {
TaskRunnerModule::class -> appModule.domainModules.taskRunnerModule
CoreAndroidModule::class -> appModule.coreAndroidModule
ShareEntryModule::class -> featureModules.shareEntryModule
ImageGalleryModule::class -> featureModules.imageGalleryModule
else -> throw IllegalArgumentException("Unknown: $klass")
} as T
}

View File

@ -43,6 +43,7 @@ import app.dapk.st.matrix.sync.internal.request.ApiToDeviceEvent
import app.dapk.st.matrix.sync.internal.room.MessageDecrypter
import app.dapk.st.messenger.MessengerActivity
import app.dapk.st.messenger.MessengerModule
import app.dapk.st.messenger.gallery.ImageGalleryModule
import app.dapk.st.navigator.IntentFactory
import app.dapk.st.navigator.MessageAttachment
import app.dapk.st.notifications.MatrixPushHandler
@ -217,6 +218,10 @@ internal class FeatureModules internal constructor(
ShareEntryModule(matrixModules.sync, matrixModules.room)
}
val imageGalleryModule by unsafeLazy {
ImageGalleryModule(context.contentResolver, coroutineDispatchers)
}
val pushModule by unsafeLazy {
domainModules.pushModule
}

View File

@ -132,7 +132,7 @@ ext.kotlinTest = { dependencies ->
dependencies.testImplementation Dependencies.mavenCentral.kluent
dependencies.testImplementation Dependencies.mavenCentral.kotlinTest
dependencies.testImplementation "org.jetbrains.kotlin:kotlin-test-junit:1.6.10"
dependencies.testImplementation 'io.mockk:mockk:1.12.8'
dependencies.testImplementation 'io.mockk:mockk:1.13.2'
dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.1'
@ -140,7 +140,7 @@ ext.kotlinTest = { dependencies ->
}
ext.kotlinFixtures = { dependencies ->
dependencies.testFixturesImplementation 'io.mockk:mockk:1.12.7'
dependencies.testFixturesImplementation 'io.mockk:mockk:1.13.1'
dependencies.testFixturesImplementation Dependencies.mavenCentral.kluent
dependencies.testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore
}

View File

@ -82,10 +82,12 @@ ext.Dependencies.with {
includeGroup "io.ktor"
includeGroup "io.coil-kt"
includeGroup "io.mockk"
includeGroup "io.perfmark"
includeGroup "info.picocli"
includeGroup "us.fatehi"
includeGroup "jakarta.xml.bind"
includeGroup "jakarta.activation"
includeGroup "javax.annotation"
includeGroup "javax.inject"
includeGroup "junit"
includeGroup "jline"
@ -102,14 +104,14 @@ ext.Dependencies.with {
google = new DependenciesContainer()
google.with {
androidGradlePlugin = "com.android.tools.build:gradle:7.2.2"
androidGradlePlugin = "com.android.tools.build:gradle:7.3.0"
androidxComposeUi = "androidx.compose.ui:ui:${composeVer}"
androidxComposeFoundation = "androidx.compose.foundation:foundation:${composeVer}"
androidxComposeMaterial = "androidx.compose.material3:material3:1.0.0-beta01"
androidxComposeIconsExtended = "androidx.compose.material:material-icons-extended:${composeVer}"
androidxActivityCompose = "androidx.activity:activity-compose:1.4.0"
kotlinCompilerExtensionVersion = "1.3.0"
kotlinCompilerExtensionVersion = "1.3.1"
firebaseCrashlyticsPlugin = "com.google.firebase:firebase-crashlytics-gradle:2.9.1"
jdkLibs = "com.android.tools:desugar_jdk_libs:1.1.5"
@ -143,7 +145,7 @@ ext.Dependencies.with {
junit = "junit:junit:4.13.2"
kluent = "org.amshove.kluent:kluent:1.68"
mockk = 'io.mockk:mockk:1.12.8'
mockk = 'io.mockk:mockk:1.13.2'
matrixOlm = "org.matrix.android:olm-sdk:3.2.12"
}

View File

@ -31,8 +31,8 @@ private fun createExtended(scheme: ColorScheme) = ExtendedColors(
onSelfBubble = scheme.onPrimary,
othersBubble = scheme.secondaryContainer,
onOthersBubble = scheme.onSecondaryContainer,
selfBubbleReplyBackground = Color(0x40EAEAEA),
otherBubbleReplyBackground = Color(0x20EAEAEA),
selfBubbleReplyBackground = scheme.primary.copy(alpha = 0.2f),
otherBubbleReplyBackground = scheme.primary.copy(alpha = 0.2f),
missingImageColors = listOf(
Color(0xFFf7c7f7) to Color(0xFFdf20de),
Color(0xFFe5d7f6) to Color(0xFF7b30cf),

View File

@ -1,15 +1,19 @@
package app.dapk.st.core
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.design.components.SmallTalkTheme
import app.dapk.st.design.components.ThemeConfig
import app.dapk.st.navigator.navigator
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import androidx.activity.compose.setContent as _setContent
abstract class DapkActivity : ComponentActivity(), EffectScope {
@ -27,7 +31,6 @@ abstract class DapkActivity : ComponentActivity(), EffectScope {
super.onCreate(savedInstanceState)
this.themeConfig = ThemeConfig(themeStore.isMaterialYouEnabled())
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
}
@ -58,10 +61,40 @@ abstract class DapkActivity : ComponentActivity(), EffectScope {
}
}
@Suppress("OVERRIDE_DEPRECATION")
override fun onBackPressed() {
if (needsBackLeakWorkaround && !onBackPressedDispatcher.hasEnabledCallbacks()) {
finishAfterTransition()
} else
} else {
super.onBackPressed()
}
}
protected suspend fun ensurePermission(permission: String): PermissionResult {
return when {
checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED -> PermissionResult.Granted
shouldShowRequestPermissionRationale(permission) -> PermissionResult.ShowRational
else -> {
val isGranted = suspendCancellableCoroutine { continuation ->
val callback: (result: Boolean) -> Unit = { result -> continuation.resume(result) }
val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission(), callback)
launcher.launch(permission)
continuation.invokeOnCancellation { launcher.unregister() }
}
when (isGranted) {
true -> PermissionResult.Granted
false -> PermissionResult.Denied
}
}
}
}
}
sealed interface PermissionResult {
object Granted : PermissionResult
object ShowRational : PermissionResult
object Denied : PermissionResult
}

View File

@ -3,5 +3,5 @@ plugins {
}
dependencies {
compileOnly 'org.json:json:20220320'
compileOnly 'org.json:json:20220924'
}

View File

@ -170,6 +170,8 @@ class OlmWrapper(
val inBound = OlmInboundGroupSession(roomCryptoSession.key)
olmStore.persist(roomCryptoSession.id, inBound)
logger.crypto("Creating megolm: ${roomCryptoSession.id}")
return roomCryptoSession
}
@ -181,7 +183,7 @@ class OlmWrapper(
private suspend fun deviceCrypto(input: OlmSessionInput): DeviceCryptoSession? {
return olmStore.readSessions(listOf(input.identity))?.let {
DeviceCryptoSession(
input.deviceId, input.userId, input.identity, input.fingerprint, it
input.deviceId, input.userId, input.identity, input.fingerprint, it.map { it.second }
)
}
}

View File

@ -27,10 +27,10 @@ internal class RoomPersistence(
private val coroutineDispatchers: CoroutineDispatchers,
) : RoomStore {
override suspend fun persist(roomId: RoomId, state: RoomState) {
override suspend fun persist(roomId: RoomId, events: List<RoomEvent>) {
coroutineDispatchers.withIoContext {
database.transaction {
state.events.forEach {
events.forEach {
database.roomEventQueries.insertRoomEvent(roomId, it)
}
}
@ -38,11 +38,18 @@ internal class RoomPersistence(
}
override suspend fun remove(rooms: List<RoomId>) {
coroutineDispatchers
coroutineDispatchers.withIoContext {
database.roomEventQueries.transaction {
rooms.forEach { database.roomEventQueries.remove(it.value) }
}
}
}
override suspend fun remove(eventId: EventId) {
coroutineDispatchers.withIoContext {
database.roomEventQueries.removeEvent(eventId.value)
}
}
override fun latest(roomId: RoomId): Flow<RoomState> {
val overviewFlow = database.overviewStateQueries.selectRoom(roomId.value).asFlow().mapToOneNotNull().map {

View File

@ -37,3 +37,7 @@ LIMIT 100;
remove:
DELETE FROM dbRoomEvent
WHERE room_id = ?;
removeEvent:
DELETE FROM dbRoomEvent
WHERE event_id = ?;

View File

@ -13,4 +13,5 @@ dependencies {
implementation project(':domains:store')
implementation project(":core")
implementation project(":design-library")
implementation Dependencies.mavenCentral.coil
}

View File

@ -27,6 +27,7 @@ class MainActivity : DapkActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
homeViewModel.events.onEach {
when (it) {
HomeEvent.Relaunch -> recreate()

View File

@ -9,6 +9,7 @@
android:windowSoftInputMode="adjustResize"/>
<activity android:name=".roomsettings.RoomSettingsActivity"/>
<activity android:name=".gallery.ImageGalleryActivity"/>
</application>

View File

@ -1,7 +1,9 @@
package app.dapk.st.messenger
import android.content.Context
import android.os.Environment
import app.dapk.st.core.Base64
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.crypto.MediaDecrypter
import app.dapk.st.matrix.sync.RoomEvent
import coil.ImageLoader
@ -14,14 +16,16 @@ import coil.request.Options
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okio.Buffer
import okio.BufferedSource
import okio.Path.Companion.toOkioPath
import java.io.File
class DecryptingFetcherFactory(private val context: Context, base64: Base64) : Fetcher.Factory<RoomEvent.Image> {
class DecryptingFetcherFactory(private val context: Context, base64: Base64, private val roomId: RoomId) : Fetcher.Factory<RoomEvent.Image> {
private val mediaDecrypter = MediaDecrypter(base64)
override fun create(data: RoomEvent.Image, options: Options, imageLoader: ImageLoader): Fetcher {
return DecryptingFetcher(data, context, mediaDecrypter)
return DecryptingFetcher(data, context, mediaDecrypter, roomId)
}
}
@ -31,23 +35,48 @@ class DecryptingFetcher(
private val data: RoomEvent.Image,
private val context: Context,
private val mediaDecrypter: MediaDecrypter,
roomId: RoomId,
) : Fetcher {
private val directory by lazy {
context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)!!.resolve("SmallTalk/${roomId.value}").also { it.mkdirs() }
}
override suspend fun fetch(): FetchResult {
val diskCacheKey = data.imageMeta.url.hashCode().toString()
val diskCachedFile = directory.resolve(diskCacheKey)
val path = diskCachedFile.toOkioPath()
return when {
diskCachedFile.exists() -> SourceResult(ImageSource(path), null, DataSource.DISK)
else -> {
diskCachedFile.createNewFile()
val response = http.newCall(Request.Builder().url(data.imageMeta.url).build()).execute()
val outputStream = when {
data.imageMeta.keys != null -> handleEncrypted(response, data.imageMeta.keys!!)
else -> response.body?.source() ?: throw IllegalArgumentException("No bitmap response found")
}
return SourceResult(ImageSource(outputStream, context), null, DataSource.NETWORK)
when {
data.imageMeta.keys != null -> response.writeDecrypted(diskCachedFile, data.imageMeta.keys!!)
else -> response.body?.source()?.writeToFile(diskCachedFile) ?: throw IllegalArgumentException("No bitmap response found")
}
private fun handleEncrypted(response: Response, keys: RoomEvent.Image.ImageMeta.Keys): Buffer {
return response.body?.byteStream()?.let { byteStream ->
Buffer().also { buffer ->
mediaDecrypter.decrypt(byteStream, keys.k, keys.iv).collect { buffer.write(it) }
SourceResult(ImageSource(path), null, DataSource.NETWORK)
}
}
}
private fun Response.writeDecrypted(file: File, keys: RoomEvent.Image.ImageMeta.Keys) {
this.body?.byteStream()?.let { byteStream ->
file.outputStream().use { output ->
mediaDecrypter.decrypt(byteStream, keys.k, keys.iv).collect { output.write(it) }
}
}
}
}
private fun BufferedSource.writeToFile(file: File) {
this.inputStream().use { input ->
file.outputStream().use { output ->
input.copyTo(output)
}
} ?: Buffer()
}
}

View File

@ -5,16 +5,16 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import android.widget.Toast
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import app.dapk.st.core.DapkActivity
import app.dapk.st.core.*
import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.core.module
import app.dapk.st.core.viewModel
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.messenger.gallery.GetImageFromGallery
import app.dapk.st.navigator.MessageAttachment
import kotlinx.parcelize.Parcelize
@ -50,11 +50,26 @@ class MessengerActivity : DapkActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val payload = readPayload<MessagerActivityPayload>()
val factory = module.decryptingFetcherFactory()
val factory = module.decryptingFetcherFactory(RoomId(payload.roomId))
val galleryLauncher = registerForActivityResult(GetImageFromGallery()) {
it?.let { uri ->
viewModel.post(
MessengerAction.ComposerImageUpdate(
MessageAttachment(
AndroidUri(it.toString()),
MimeType.Image,
)
)
)
}
}
setContent {
Surface(Modifier.fillMaxSize()) {
CompositionLocalProvider(LocalDecyptingFetcherFactory provides factory) {
MessengerScreen(RoomId(payload.roomId), payload.attachments, viewModel, navigator)
MessengerScreen(RoomId(payload.roomId), payload.attachments, viewModel, navigator, galleryLauncher)
}
}
}

View File

@ -4,6 +4,7 @@ import android.content.Context
import app.dapk.st.core.Base64
import app.dapk.st.core.ProvidableModule
import app.dapk.st.matrix.common.CredentialsStore
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.message.MessageService
import app.dapk.st.matrix.room.RoomService
import app.dapk.st.matrix.sync.RoomStore
@ -30,5 +31,5 @@ class MessengerModule(
return TimelineUseCaseImpl(syncService, messageService, roomService, mergeWithLocalEchosUseCase)
}
internal fun decryptingFetcherFactory() = DecryptingFetcherFactory(context, base64)
internal fun decryptingFetcherFactory(roomId: RoomId) = DecryptingFetcherFactory(context, base64, roomId)
}

View File

@ -1,9 +1,11 @@
package app.dapk.st.messenger
import android.content.res.Configuration
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.CircleShape
@ -13,6 +15,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.*
import androidx.compose.runtime.*
@ -33,6 +36,7 @@ import app.dapk.st.core.Lce
import app.dapk.st.core.LifecycleEffect
import app.dapk.st.core.StartObserving
import app.dapk.st.core.components.CenteredLoading
import app.dapk.st.core.extensions.takeIfContent
import app.dapk.st.design.components.*
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.UserId
@ -40,6 +44,7 @@ import app.dapk.st.matrix.sync.MessageMeta
import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.RoomEvent.Message
import app.dapk.st.matrix.sync.RoomState
import app.dapk.st.messenger.gallery.ImageGalleryActivityPayload
import app.dapk.st.navigator.MessageAttachment
import app.dapk.st.navigator.Navigator
import coil.compose.rememberAsyncImagePainter
@ -47,10 +52,16 @@ import coil.request.ImageRequest
import kotlinx.coroutines.launch
@Composable
internal fun MessengerScreen(roomId: RoomId, attachments: List<MessageAttachment>?, viewModel: MessengerViewModel, navigator: Navigator) {
internal fun MessengerScreen(
roomId: RoomId,
attachments: List<MessageAttachment>?,
viewModel: MessengerViewModel,
navigator: Navigator,
galleryLauncher: ActivityResultLauncher<ImageGalleryActivityPayload>
) {
val state = viewModel.state
viewModel.ObserveEvents()
viewModel.ObserveEvents(galleryLauncher)
LifecycleEffect(
onStart = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) },
onStop = { viewModel.post(MessengerAction.OnMessengerGone) }
@ -63,9 +74,9 @@ internal fun MessengerScreen(roomId: RoomId, attachments: List<MessageAttachment
Column {
Toolbar(onNavigate = { navigator.navigate.upToHome() }, roomTitle, actions = {
OverflowMenu {
DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {})
}
// OverflowMenu {
// DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {})
// }
})
when (state.composerState) {
is ComposerState.Text -> {
@ -74,6 +85,7 @@ internal fun MessengerScreen(roomId: RoomId, attachments: List<MessageAttachment
state.composerState,
onTextChange = { viewModel.post(MessengerAction.ComposerTextUpdate(it)) },
onSend = { viewModel.post(MessengerAction.ComposerSendText) },
onAttach = { viewModel.startAttachment() }
)
}
@ -89,10 +101,16 @@ internal fun MessengerScreen(roomId: RoomId, attachments: List<MessageAttachment
}
@Composable
private fun MessengerViewModel.ObserveEvents() {
private fun MessengerViewModel.ObserveEvents(galleryLauncher: ActivityResultLauncher<ImageGalleryActivityPayload>) {
StartObserving {
this@ObserveEvents.events.launch {
// TODO()
when (it) {
MessengerEvent.SelectImageAttachment -> {
state.roomState.takeIfContent()?.let {
galleryLauncher.launch(ImageGalleryActivityPayload(it.roomState.roomOverview.roomName ?: ""))
}
}
}
}
}
}
@ -258,6 +276,7 @@ private fun MessageImage(content: BubbleContent<RoomEvent.Image>) {
painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context)
.fetcherFactory(LocalDecyptingFetcherFactory.current)
.memoryCacheKey(content.message.imageMeta.url)
.data(content.message)
.build()
),
@ -408,6 +427,7 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
val context = LocalContext.current
Column(
Modifier
.fillMaxWidth()
.background(if (content.isNotSelf) SmallTalkTheme.extendedColors.otherBubbleReplyBackground else SmallTalkTheme.extendedColors.selfBubbleReplyBackground)
.padding(4.dp)
) {
@ -417,13 +437,13 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
fontSize = 11.sp,
text = replyName,
maxLines = 1,
color = MaterialTheme.colorScheme.onPrimary
color = content.textColor()
)
when (val replyingTo = content.message.replyingTo) {
is Message -> {
Text(
text = replyingTo.content,
color = MaterialTheme.colorScheme.onPrimary,
color = content.textColor(),
fontSize = 15.sp,
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
@ -437,6 +457,7 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context)
.fetcherFactory(LocalDecyptingFetcherFactory.current)
.memoryCacheKey(replyingTo.imageMeta.url)
.data(replyingTo)
.build()
),
@ -478,7 +499,8 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
modifier = Modifier.size(message.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)),
painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context)
.data(content.message)
.data(message)
.memoryCacheKey(message.imageMeta.url)
.fetcherFactory(LocalDecyptingFetcherFactory.current)
.build()
),
@ -550,7 +572,7 @@ private fun RowScope.SendStatus(message: RoomEvent) {
}
@Composable
private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Unit, onSend: () -> Unit) {
private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Unit, onSend: () -> Unit, onAttach: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
@ -576,7 +598,17 @@ private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Un
onValueChange = { onTextChange(it) },
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
textStyle = LocalTextStyle.current.copy(color = SmallTalkTheme.extendedColors.onOthersBubble),
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, autoCorrect = true)
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, autoCorrect = true),
decorationBox = { innerField ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Box(modifier = Modifier.weight(1f).padding(end = 4.dp)) { innerField() }
Icon(
modifier = Modifier.clickable { onAttach() }.wrapContentWidth().align(Alignment.Bottom),
imageVector = Icons.Filled.Image,
contentDescription = "",
)
}
}
)
}
}

View File

@ -10,7 +10,9 @@ data class MessengerScreenState(
val composerState: ComposerState,
)
sealed interface MessengerEvent
sealed interface MessengerEvent {
object SelectImageAttachment : MessengerEvent
}
sealed interface ComposerState {

View File

@ -48,6 +48,7 @@ internal class MessengerViewModel(
is MessengerAction.ComposerTextUpdate -> updateState { copy(composerState = ComposerState.Text(action.newValue)) }
MessengerAction.ComposerSendText -> sendMessage()
MessengerAction.ComposerClear -> updateState { copy(composerState = ComposerState.Text("")) }
is MessengerAction.ComposerImageUpdate -> updateState { copy(composerState = ComposerState.Attachments(listOf(action.newValue))) }
}
}
@ -100,6 +101,7 @@ internal class MessengerViewModel(
}
}
}
is ComposerState.Attachments -> {
val copy = composerState.copy()
updateState { copy(composerState = ComposerState.Text("")) }
@ -123,6 +125,12 @@ internal class MessengerViewModel(
}
}
fun startAttachment() {
viewModelScope.launch {
_events.emit(MessengerEvent.SelectImageAttachment)
}
}
}
private fun MessengerState.latestMessageEventFromOthers(self: UserId) = this.roomState.events
@ -133,6 +141,7 @@ private fun MessengerState.latestMessageEventFromOthers(self: UserId) = this.roo
sealed interface MessengerAction {
data class ComposerTextUpdate(val newValue: String) : MessengerAction
data class ComposerImageUpdate(val newValue: MessageAttachment) : MessengerAction
object ComposerSendText : MessengerAction
object ComposerClear : MessengerAction
data class OnMessengerVisible(val roomId: RoomId, val attachments: List<MessageAttachment>?) : MessengerAction

View File

@ -0,0 +1,53 @@
package app.dapk.st.messenger.gallery
import android.content.ContentResolver
import android.content.ContentUris
import android.net.Uri
import android.provider.MediaStore.Images
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.withIoContext
class FetchMediaFoldersUseCase(
private val contentResolver: ContentResolver,
private val dispatchers: CoroutineDispatchers,
) {
suspend fun fetchFolders(): List<Folder> {
return dispatchers.withIoContext {
val projection = arrayOf(Images.Media._ID, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_MODIFIED)
val selection = "${isNotPending()} AND ${Images.Media.BUCKET_ID} AND ${Images.Media.MIME_TYPE} NOT LIKE ?"
val sortBy = "${Images.Media.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC, ${Images.Media.DATE_MODIFIED} DESC"
val folders = mutableMapOf<String, Folder>()
val contentUri = Images.Media.EXTERNAL_CONTENT_URI
contentResolver.query(contentUri, projection, selection, arrayOf("%image/svg%"), sortBy).use { cursor ->
while (cursor != null && cursor.moveToNext()) {
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0]))
val thumbnail = ContentUris.withAppendedId(contentUri, rowId)
val bucketId = cursor.getString(cursor.getColumnIndexOrThrow(projection[1]))
val title = cursor.getString(cursor.getColumnIndexOrThrow(projection[2])) ?: ""
val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(projection[3]))
val folder = folders.getOrPut(bucketId) { Folder(bucketId, title, thumbnail) }
folder.incrementItemCount()
}
}
folders.values.toList()
}
}
}
data class Folder(
val bucketId: String,
val title: String,
val thumbnail: Uri,
) {
private var _itemCount: Long = 0L
val itemCount: Long
get() = _itemCount
fun incrementItemCount() {
_itemCount++
}
}

View File

@ -0,0 +1,64 @@
package app.dapk.st.messenger.gallery
import android.content.ContentResolver
import android.content.ContentUris
import android.net.Uri
import android.provider.MediaStore
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.withIoContext
class FetchMediaUseCase(private val contentResolver: ContentResolver, private val dispatchers: CoroutineDispatchers) {
private val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.MIME_TYPE,
MediaStore.Images.Media.DATE_MODIFIED,
MediaStore.Images.Media.ORIENTATION,
MediaStore.Images.Media.WIDTH,
MediaStore.Images.Media.HEIGHT,
MediaStore.Images.Media.SIZE
)
private val selection = MediaStore.Images.Media.BUCKET_ID + " = ? AND " + isNotPending() + " AND " + MediaStore.Images.Media.MIME_TYPE + " NOT LIKE ?"
suspend fun getMediaInBucket(bucketId: String): List<Media> {
return dispatchers.withIoContext {
val media = mutableListOf<Media>()
val selectionArgs = arrayOf(bucketId, "%image/svg%")
val sortBy = MediaStore.Images.Media.DATE_MODIFIED + " DESC"
val contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
contentResolver.query(contentUri, projection, selection, selectionArgs, sortBy).use { cursor ->
while (cursor != null && cursor.moveToNext()) {
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0]))
val uri = ContentUris.withAppendedId(contentUri, rowId)
val mimetype = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE))
val date = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED))
val orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION))
val width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation)))
val height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation)))
val size = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE))
media.add(Media(rowId, uri, mimetype, width, height, size, date))
}
}
media
}
}
private fun getWidthColumn(orientation: Int) = if (orientation == 0 || orientation == 180) MediaStore.Images.Media.WIDTH else MediaStore.Images.Media.HEIGHT
private fun getHeightColumn(orientation: Int) =
if (orientation == 0 || orientation == 180) MediaStore.Images.Media.HEIGHT else MediaStore.Images.Media.WIDTH
}
data class Media(
val id: Long,
val uri: Uri,
val mimeType: String,
val width: Int,
val height: Int,
val size: Long,
val dateModifiedEpochMillis: Long,
)

View File

@ -0,0 +1,86 @@
package app.dapk.st.messenger.gallery
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.lifecycleScope
import app.dapk.st.core.*
import app.dapk.st.core.extensions.unsafeLazy
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
class ImageGalleryActivity : DapkActivity() {
private val module by unsafeLazy { module<ImageGalleryModule>() }
private val viewModel by viewModel {
val payload = intent.getParcelableExtra("key") as? ImageGalleryActivityPayload
module.imageGalleryViewModel(payload!!.roomName)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val permissionState = mutableStateOf<Lce<PermissionResult>>(Lce.Loading())
lifecycleScope.launch {
permissionState.value = runCatching { ensurePermission(Manifest.permission.READ_EXTERNAL_STORAGE) }.fold(
onSuccess = { Lce.Content(it) },
onFailure = { Lce.Error(it) }
)
}
setContent {
Surface {
PermissionGuard(permissionState) {
ImageGalleryScreen(viewModel, onTopLevelBack = { finish() }) { media ->
setResult(RESULT_OK, Intent().setData(media.uri))
finish()
}
}
}
}
}
}
@Composable
fun Activity.PermissionGuard(state: State<Lce<PermissionResult>>, onGranted: @Composable () -> Unit) {
when (val content = state.value) {
is Lce.Content -> when (content.value) {
PermissionResult.Granted -> onGranted()
PermissionResult.Denied -> finish()
PermissionResult.ShowRational -> finish()
}
is Lce.Error -> finish()
is Lce.Loading -> {
// loading should be quick, let's avoid displaying anything
}
}
}
class GetImageFromGallery : ActivityResultContract<ImageGalleryActivityPayload, Uri?>() {
override fun createIntent(context: Context, input: ImageGalleryActivityPayload): Intent {
return Intent(context, ImageGalleryActivity::class.java)
.putExtra("key", input)
}
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
return intent.takeIf { resultCode == Activity.RESULT_OK }?.data
}
}
@Parcelize
data class ImageGalleryActivityPayload(
val roomName: String,
) : Parcelable

View File

@ -0,0 +1,18 @@
package app.dapk.st.messenger.gallery
import android.content.ContentResolver
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.ProvidableModule
class ImageGalleryModule(
private val contentResolver: ContentResolver,
private val dispatchers: CoroutineDispatchers,
) : ProvidableModule {
fun imageGalleryViewModel(roomName: String) = ImageGalleryViewModel(
FetchMediaFoldersUseCase(contentResolver, dispatchers),
FetchMediaUseCase(contentResolver, dispatchers),
roomName = roomName,
)
}

View File

@ -0,0 +1,157 @@
package app.dapk.st.messenger.gallery
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.dapk.st.core.Lce
import app.dapk.st.core.LifecycleEffect
import app.dapk.st.core.components.CenteredLoading
import app.dapk.st.design.components.GenericError
import app.dapk.st.design.components.Spider
import app.dapk.st.design.components.SpiderPage
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
@Composable
fun ImageGalleryScreen(viewModel: ImageGalleryViewModel, onTopLevelBack: () -> Unit, onImageSelected: (Media) -> Unit) {
LifecycleEffect(onStart = {
viewModel.start()
})
val onNavigate: (SpiderPage<out ImageGalleryPage>?) -> Unit = {
when (it) {
null -> onTopLevelBack()
else -> viewModel.goTo(it)
}
}
Spider(currentPage = viewModel.state.page, onNavigate = onNavigate) {
item(ImageGalleryPage.Routes.folders) {
ImageGalleryFolders(it) { folder ->
viewModel.selectFolder(folder)
}
}
item(ImageGalleryPage.Routes.files) {
ImageGalleryMedia(it, onImageSelected)
}
}
}
@Composable
fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Unit) {
val screenWidth = LocalConfiguration.current.screenWidthDp
val gradient = Brush.verticalGradient(
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.5f)),
)
when (val content = state.content) {
is Lce.Loading -> {
CenteredLoading()
}
is Lce.Content -> {
Column {
val columns = when {
screenWidth > 600 -> 4
else -> 2
}
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
modifier = Modifier.fillMaxSize(),
) {
items(content.value, key = { it.bucketId }) {
Box(modifier = Modifier.fillMaxWidth().padding(2.dp).aspectRatio(1f)
.clickable { onClick(it) }) {
Image(
painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current)
.data(it.thumbnail.toString())
.build(),
),
contentDescription = "123",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(modifier = Modifier.fillMaxWidth().fillMaxHeight(0.6f).background(gradient).align(Alignment.BottomStart))
Row(
modifier = Modifier.fillMaxWidth().align(Alignment.BottomStart).padding(4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(it.title, fontSize = 13.sp, color = Color.White)
Text(it.itemCount.toString(), fontSize = 11.sp, color = Color.White)
}
}
}
}
}
}
is Lce.Error -> GenericError { }
}
}
@Composable
fun ImageGalleryMedia(state: ImageGalleryPage.Files, onFileSelected: (Media) -> Unit) {
val screenWidth = LocalConfiguration.current.screenWidthDp
Column {
val columns = when {
screenWidth > 600 -> 4
else -> 2
}
when (val content = state.content) {
is Lce.Loading -> {
CenteredLoading()
}
is Lce.Content -> {
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
modifier = Modifier.fillMaxSize(),
) {
val modifier = Modifier.fillMaxWidth().padding(2.dp).aspectRatio(1f)
items(content.value, key = { it.id }) {
Box(modifier = modifier.clickable { onFileSelected(it) }) {
Image(
painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current)
.data(it.uri.toString())
.crossfade(true)
.build(),
),
contentDescription = "123",
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
contentScale = ContentScale.Crop
)
}
}
}
}
is Lce.Error -> GenericError { }
}
}
}

View File

@ -0,0 +1,89 @@
package app.dapk.st.messenger.gallery
import androidx.lifecycle.viewModelScope
import app.dapk.st.core.Lce
import app.dapk.st.design.components.Route
import app.dapk.st.design.components.SpiderPage
import app.dapk.st.viewmodel.DapkViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
class ImageGalleryViewModel(
private val foldersUseCase: FetchMediaFoldersUseCase,
private val fetchMediaUseCase: FetchMediaUseCase,
roomName: String,
) : DapkViewModel<ImageGalleryState, ImageGalleryEvent>(
initialState = ImageGalleryState(
page = SpiderPage(
route = ImageGalleryPage.Routes.folders,
label = "Send to $roomName",
parent = null,
state = ImageGalleryPage.Folders(Lce.Loading())
)
)
) {
private var currentPageJob: Job? = null
fun start() {
currentPageJob?.cancel()
currentPageJob = viewModelScope.launch {
val folders = foldersUseCase.fetchFolders()
updatePageState<ImageGalleryPage.Folders> { copy(content = Lce.Content(folders)) }
}
}
fun goTo(page: SpiderPage<out ImageGalleryPage>) {
currentPageJob?.cancel()
updateState { copy(page = page) }
}
fun selectFolder(folder: Folder) {
currentPageJob?.cancel()
updateState {
copy(
page = SpiderPage(
route = ImageGalleryPage.Routes.files,
label = page.label,
parent = ImageGalleryPage.Routes.folders,
state = ImageGalleryPage.Files(Lce.Loading())
)
)
}
currentPageJob = viewModelScope.launch {
val media = fetchMediaUseCase.getMediaInBucket(folder.bucketId)
updatePageState<ImageGalleryPage.Files> {
copy(content = Lce.Content(media))
}
}
}
@Suppress("UNCHECKED_CAST")
private inline fun <reified S : ImageGalleryPage> updatePageState(crossinline block: S.() -> S) {
val page = state.page
val currentState = page.state
require(currentState is S)
updateState { copy(page = (page as SpiderPage<S>).copy(state = block(page.state))) }
}
}
data class ImageGalleryState(
val page: SpiderPage<out ImageGalleryPage>,
)
sealed interface ImageGalleryPage {
data class Folders(val content: Lce<List<Folder>>) : ImageGalleryPage
data class Files(val content: Lce<List<Media>>) : ImageGalleryPage
object Routes {
val folders = Route<Folders>("Folders")
val files = Route<Files>("Files")
}
}
sealed interface ImageGalleryEvent

View File

@ -0,0 +1,6 @@
package app.dapk.st.messenger.gallery
import android.os.Build
import android.provider.MediaStore
fun isNotPending() = if (Build.VERSION.SDK_INT <= 28) MediaStore.Images.Media.DATA + " NOT NULL" else MediaStore.MediaColumns.IS_PENDING + " != 1"

View File

@ -43,7 +43,11 @@ data class ServiceDependencies(
interface MatrixServiceInstaller {
fun serializers(builder: SerializersModuleBuilder.() -> Unit)
fun install(factory: MatrixService.Factory)
fun <T : MatrixService> install(factory: MatrixService.Factory): InstallExtender<T>
}
interface InstallExtender<T : MatrixService> {
fun proxy(proxy: (T) -> T)
}
interface MatrixServiceProvider {

View File

@ -11,15 +11,22 @@ internal class ServiceInstaller {
private val services = mutableMapOf<Any, MatrixService>()
private val serviceInstaller = object : MatrixServiceInstaller {
val serviceCollector = mutableListOf<MatrixService.Factory>()
val serviceCollector = mutableListOf<Pair<MatrixService.Factory, (MatrixService) -> MatrixService>>()
val serializers = mutableListOf<SerializersModuleBuilder.() -> Unit>()
override fun serializers(builder: SerializersModuleBuilder.() -> Unit) {
serializers.add(builder)
}
override fun install(factory: MatrixService.Factory) {
serviceCollector.add(factory)
override fun <T : MatrixService> install(factory: MatrixService.Factory): InstallExtender<T> {
val mutableProxy = MutableProxy<T>()
return object : InstallExtender<T> {
override fun proxy(proxy: (T) -> T) {
mutableProxy.value = proxy
}
}.also {
serviceCollector.add(factory to mutableProxy)
}
}
}
@ -39,9 +46,9 @@ internal class ServiceInstaller {
val serviceProvider = object : MatrixServiceProvider {
override fun <T : MatrixService> getService(key: ServiceKey) = this@ServiceInstaller.getService<T>(key)
}
serviceInstaller.serviceCollector.forEach {
val (key, service) = it.create(ServiceDependencies(httpClient, json, serviceProvider, logger))
services[key] = service
serviceInstaller.serviceCollector.forEach { (factory, extender) ->
val (key, service) = factory.create(ServiceDependencies(httpClient, json, serviceProvider, logger))
services[key] = extender(service)
}
}
@ -58,3 +65,12 @@ internal class ServiceInstaller {
}
}
internal class MutableProxy<T : MatrixService> : (MatrixService) -> MatrixService {
var value: (T) -> T = { it }
@Suppress("UNCHECKED_CAST")
override fun invoke(service: MatrixService) = value(service as T)
}

View File

@ -1,5 +1,6 @@
package app.dapk.st.matrix.auth
import app.dapk.st.matrix.InstallExtender
import app.dapk.st.matrix.MatrixClient
import app.dapk.st.matrix.MatrixService
import app.dapk.st.matrix.MatrixServiceInstaller
@ -25,8 +26,8 @@ interface AuthService : MatrixService {
fun MatrixServiceInstaller.installAuthService(
credentialsStore: CredentialsStore,
) {
this.install { (httpClient, json) ->
): InstallExtender<AuthService> {
return this.install { (httpClient, json) ->
SERVICE_KEY to DefaultAuthService(httpClient, credentialsStore, json)
}
}

View File

@ -2,10 +2,7 @@ package app.dapk.st.matrix.crypto
import app.dapk.st.core.Base64
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.matrix.MatrixService
import app.dapk.st.matrix.MatrixServiceInstaller
import app.dapk.st.matrix.MatrixServiceProvider
import app.dapk.st.matrix.ServiceDepFactory
import app.dapk.st.matrix.*
import app.dapk.st.matrix.common.*
import app.dapk.st.matrix.crypto.internal.*
import app.dapk.st.matrix.device.deviceService
@ -136,8 +133,8 @@ fun MatrixServiceInstaller.installCryptoService(
roomMembersProvider: ServiceDepFactory<RoomMembersProvider>,
base64: Base64,
coroutineDispatchers: CoroutineDispatchers,
) {
this.install { (_, _, services, logger) ->
): InstallExtender<CryptoService> {
return this.install { (_, _, services, logger) ->
val deviceService = services.deviceService()
val accountCryptoUseCase = FetchAccountCryptoUseCaseImpl(credentialsStore, olm, deviceService)

View File

@ -1,5 +1,6 @@
package app.dapk.st.matrix.device
import app.dapk.st.matrix.InstallExtender
import app.dapk.st.matrix.MatrixService
import app.dapk.st.matrix.MatrixServiceInstaller
import app.dapk.st.matrix.MatrixServiceProvider
@ -122,8 +123,8 @@ sealed class ToDevicePayload {
sealed interface VerificationPayload
}
fun MatrixServiceInstaller.installEncryptionService(knownDeviceStore: KnownDeviceStore) {
this.install { (httpClient, _, _, logger) ->
fun MatrixServiceInstaller.installEncryptionService(knownDeviceStore: KnownDeviceStore): InstallExtender<DeviceService> {
return this.install { (httpClient, _, _, logger) ->
SERVICE_KEY to DefaultDeviceService(httpClient, logger, knownDeviceStore)
}
}

View File

@ -1,10 +1,7 @@
package app.dapk.st.matrix.message
import app.dapk.st.core.Base64
import app.dapk.st.matrix.MatrixService
import app.dapk.st.matrix.MatrixServiceInstaller
import app.dapk.st.matrix.MatrixServiceProvider
import app.dapk.st.matrix.ServiceDepFactory
import app.dapk.st.matrix.*
import app.dapk.st.matrix.common.AlgorithmName
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.MessageType
@ -132,8 +129,8 @@ fun MatrixServiceInstaller.installMessageService(
imageContentReader: ImageContentReader,
messageEncrypter: ServiceDepFactory<MessageEncrypter> = ServiceDepFactory { MissingMessageEncrypter },
mediaEncrypter: ServiceDepFactory<MediaEncrypter> = ServiceDepFactory { MissingMediaEncrypter },
) {
this.install { (httpClient, _, installedServices) ->
): InstallExtender<MessageService> {
return this.install { (httpClient, _, installedServices) ->
SERVICE_KEY to DefaultMessageService(
httpClient,
localEchoStore,

View File

@ -45,6 +45,7 @@ sealed class ApiMessage {
data class Info(
@SerialName("h") val height: Int,
@SerialName("w") val width: Int,
@SerialName("mimetype") val mimeType: String,
@SerialName("size") val size: Long,
)

View File

@ -1,6 +1,9 @@
package app.dapk.st.matrix.message.internal
import app.dapk.st.matrix.common.*
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.EventType
import app.dapk.st.matrix.common.JsonString
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.http.MatrixHttpClient
import app.dapk.st.matrix.http.MatrixHttpClient.HttpRequest
import app.dapk.st.matrix.message.ApiSendResponse
@ -57,7 +60,7 @@ internal class SendMessageUseCase(
}
}
private suspend fun ApiMessageMapper.imageMessageRequest(message: Message.ImageMessage): HttpRequest<ApiSendResponse> {
private suspend fun imageMessageRequest(message: Message.ImageMessage): HttpRequest<ApiSendResponse> {
val imageMeta = imageContentReader.meta(message.content.uri)
return when (message.sendEncrypted) {
@ -91,11 +94,11 @@ internal class SendMessageUseCase(
info = ApiMessage.ImageMessage.ImageContent.Info(
height = imageMeta.height,
width = imageMeta.width,
size = imageMeta.size
size = imageMeta.size,
mimeType = imageMeta.mimeType,
)
)
val json = JsonString(
MatrixHttpClient.jsonWithDefaults.encodeToString(
ApiMessage.ImageMessage.serializer(),
@ -134,7 +137,8 @@ internal class SendMessageUseCase(
ApiMessage.ImageMessage.ImageContent.Info(
height = imageMeta.height,
width = imageMeta.width,
size = imageMeta.size
size = imageMeta.size,
mimeType = imageMeta.mimeType,
)
),
)
@ -163,14 +167,4 @@ class ApiMessageMapper {
)
)
fun Message.ImageMessage.toContents(uri: MxUrl, image: ImageContentReader.ImageContent) = ApiMessage.ImageMessage.ImageContent(
url = uri,
filename = image.fileName,
ApiMessage.ImageMessage.ImageContent.Info(
height = image.height,
width = image.width,
size = image.size
)
)
}

View File

@ -1,6 +1,7 @@
package app.dapk.st.matrix.room
import app.dapk.st.core.SingletonFlows
import app.dapk.st.matrix.InstallExtender
import app.dapk.st.matrix.MatrixService
import app.dapk.st.matrix.MatrixServiceInstaller
import app.dapk.st.matrix.MatrixServiceProvider
@ -29,8 +30,8 @@ fun MatrixServiceInstaller.installProfileService(
profileStore: ProfileStore,
singletonFlows: SingletonFlows,
credentialsStore: CredentialsStore,
) {
this.install { (httpClient, _, _, logger) ->
): InstallExtender<ProfileService> {
return this.install { (httpClient, _, _, logger) ->
SERVICE_KEY to DefaultProfileService(httpClient, logger, profileStore, singletonFlows, credentialsStore)
}
}

View File

@ -1,5 +1,6 @@
package app.dapk.st.matrix.push
import app.dapk.st.matrix.InstallExtender
import app.dapk.st.matrix.MatrixClient
import app.dapk.st.matrix.MatrixService
import app.dapk.st.matrix.MatrixServiceInstaller
@ -38,8 +39,8 @@ interface PushService : MatrixService {
fun MatrixServiceInstaller.installPushService(
credentialsStore: CredentialsStore,
) {
this.install { (httpClient, _, _, logger) ->
): InstallExtender<PushService> {
return this.install { (httpClient, _, _, logger) ->
SERVICE_KEY to DefaultPushService(httpClient, credentialsStore, logger)
}
}

View File

@ -1,9 +1,6 @@
package app.dapk.st.matrix.room
import app.dapk.st.matrix.MatrixService
import app.dapk.st.matrix.MatrixServiceInstaller
import app.dapk.st.matrix.MatrixServiceProvider
import app.dapk.st.matrix.ServiceDepFactory
import app.dapk.st.matrix.*
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.RoomMember
@ -42,8 +39,8 @@ fun MatrixServiceInstaller.installRoomService(
memberStore: MemberStore,
roomMessenger: ServiceDepFactory<RoomMessenger>,
roomInviteRemover: RoomInviteRemover,
) {
this.install { (httpClient, _, services, logger) ->
): InstallExtender<RoomService> {
return this.install { (httpClient, _, services, logger) ->
SERVICE_KEY to DefaultRoomService(
httpClient,
logger,

View File

@ -35,6 +35,7 @@ sealed class RoomEvent {
@SerialName("meta") override val meta: MessageMeta,
@SerialName("encrypted_content") val encryptedContent: MegOlmV1? = null,
@SerialName("edited") val edited: Boolean = false,
@SerialName("redacted") val redacted: Boolean = false,
) : RoomEvent() {
@Serializable

View File

@ -7,8 +7,9 @@ import kotlinx.coroutines.flow.Flow
interface RoomStore {
suspend fun persist(roomId: RoomId, state: RoomState)
suspend fun persist(roomId: RoomId, events: List<RoomEvent>)
suspend fun remove(rooms: List<RoomId>)
suspend fun remove(eventId: EventId)
suspend fun retrieve(roomId: RoomId): RoomState?
fun latest(roomId: RoomId): Flow<RoomState>
suspend fun insertUnread(roomId: RoomId, eventIds: List<EventId>)

View File

@ -2,10 +2,7 @@ package app.dapk.st.matrix.sync
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.matrix.MatrixClient
import app.dapk.st.matrix.MatrixService
import app.dapk.st.matrix.MatrixServiceInstaller
import app.dapk.st.matrix.ServiceDepFactory
import app.dapk.st.matrix.*
import app.dapk.st.matrix.common.*
import app.dapk.st.matrix.sync.internal.DefaultSyncService
import app.dapk.st.matrix.sync.internal.request.*
@ -49,7 +46,7 @@ fun MatrixServiceInstaller.installSyncService(
errorTracker: ErrorTracker,
coroutineDispatchers: CoroutineDispatchers,
syncConfig: SyncConfig = SyncConfig(),
) {
): InstallExtender<SyncService> {
this.serializers {
polymorphicDefault(ApiTimelineEvent::class) {
ApiTimelineEvent.Ignored.serializer()
@ -71,7 +68,7 @@ fun MatrixServiceInstaller.installSyncService(
}
}
this.install { (httpClient, json, services, logger) ->
return this.install { (httpClient, json, services, logger) ->
SERVICE_KEY to DefaultSyncService(
httpClient = httpClient,
syncStore = syncStore,

View File

@ -82,7 +82,6 @@ internal sealed class ApiTimelineEvent {
)
}
@Serializable
@SerialName("m.room.member")
internal data class RoomMember(
@ -109,6 +108,15 @@ internal sealed class ApiTimelineEvent {
}
}
@Serializable
@SerialName("m.room.redaction")
internal data class RoomRedcation(
@SerialName("event_id") val id: EventId,
@SerialName("redacts") val redactedId: EventId,
@SerialName("origin_server_ts") val utcTimestamp: Long,
@SerialName("sender") val senderId: UserId,
) : ApiTimelineEvent()
@Serializable
internal data class DecryptionStatus(
@SerialName("is_verified") val isVerified: Boolean

View File

@ -1,9 +1,7 @@
package app.dapk.st.matrix.sync.internal.sync
import app.dapk.st.matrix.common.MatrixLogTag
import app.dapk.st.matrix.common.MatrixLogger
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.matrixLog
import app.dapk.st.matrix.common.*
import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.RoomState
import app.dapk.st.matrix.sync.RoomStore
@ -26,7 +24,7 @@ class RoomDataSource(
logger.matrixLog(MatrixLogTag.SYNC, "no changes, not persisting")
} else {
roomCache[roomId] = newState
roomStore.persist(roomId, newState)
roomStore.persist(roomId, newState.events)
}
}
@ -34,4 +32,35 @@ class RoomDataSource(
roomsLeft.forEach { roomCache.remove(it) }
roomStore.remove(roomsLeft)
}
suspend fun redact(roomId: RoomId, event: EventId) {
val eventToRedactFromCache = roomCache[roomId]?.events?.find { it.eventId == event }
val redactedEvent = when {
eventToRedactFromCache != null -> {
eventToRedactFromCache.redact().also { redacted ->
val cachedRoomState = roomCache[roomId]
requireNotNull(cachedRoomState)
roomCache[roomId] = cachedRoomState.replaceEvent(eventToRedactFromCache, redacted)
}
}
else -> roomStore.findEvent(event)?.redact()
}
redactedEvent?.let { roomStore.persist(roomId, listOf(it)) }
}
}
private fun RoomEvent.redact() = when (this) {
is RoomEvent.Image -> RoomEvent.Message(this.eventId, this.utcTimestamp, "Redacted", this.author, this.meta, redacted = true)
is RoomEvent.Message -> RoomEvent.Message(this.eventId, this.utcTimestamp, "Redacted", this.author, this.meta, redacted = true)
is RoomEvent.Reply -> RoomEvent.Message(this.eventId, this.utcTimestamp, "Redacted", this.author, this.meta, redacted = true)
}
private fun RoomState.replaceEvent(old: RoomEvent, new: RoomEvent): RoomState {
val updatedEvents = this.events.toMutableList().apply {
remove(old)
add(new)
}
return this.copy(events = updatedEvents)
}

View File

@ -21,6 +21,10 @@ internal class RoomProcessor(
val members = roomToProcess.apiSyncRoom.collectMembers(roomToProcess.userCredentials)
roomMembersService.insert(roomToProcess.roomId, members)
roomToProcess.apiSyncRoom.timeline.apiTimelineEvents.filterIsInstance<ApiTimelineEvent.RoomRedcation>().forEach {
roomDataSource.redact(roomToProcess.roomId, it.redactedId)
}
val previousState = roomDataSource.read(roomToProcess.roomId)
val (newEvents, distinctEvents) = timelineEventsProcessor.process(

View File

@ -61,8 +61,6 @@ internal class SyncReducer(
}
}
roomDataSource.remove(roomsLeft)
return ReducerResult(
newRooms,
(apiRoomsToProcess + roomsWithSideEffects).awaitAll().filterNotNull(),

View File

@ -8,7 +8,6 @@ import app.dapk.st.matrix.sync.internal.SideEffectFlowIterator
import app.dapk.st.matrix.sync.internal.overview.ReducedSyncFilterUseCase
import app.dapk.st.matrix.sync.internal.request.syncRequest
import app.dapk.st.matrix.sync.internal.room.SyncSideEffects
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.cancellable
import kotlinx.coroutines.flow.flow
@ -25,8 +24,7 @@ internal class SyncUseCase(
private val syncConfig: SyncConfig,
) {
fun sync(): Flow<Unit> {
return flow {
private val _flow = flow {
val credentials = credentialsStore.credentials()!!
val filterId = filterUseCase.reducedFilter(credentials.userId)
with(flowIterator) {
@ -37,7 +35,6 @@ internal class SyncUseCase(
)
}
}.cancellable()
}
private suspend fun onEachSyncIteration(filterId: SyncService.FilterId, credentials: UserCredentials, previousState: OverviewState?): OverviewState? {
val syncToken = syncStore.read(key = SyncStore.SyncKey.Overview)
@ -85,4 +82,6 @@ internal class SyncUseCase(
)
}
fun sync() = _flow
}

View File

@ -32,6 +32,7 @@ internal class TimelineEventsProcessor(
is ApiTimelineEvent.TimelineMessage -> event.toRoomEvent(roomToProcess.userCredentials, roomToProcess.roomId) { eventId ->
eventLookupUseCase.lookup(eventId, decryptedTimeline, decryptedPreviousEvents)
}
is ApiTimelineEvent.RoomRedcation -> null
is ApiTimelineEvent.Encryption -> null
is ApiTimelineEvent.RoomAvatar -> null
is ApiTimelineEvent.RoomCreate -> null

View File

@ -9,7 +9,7 @@ test {
dependencies {
kotlinTest(it)
testImplementation 'app.cash.turbine:turbine:0.10.0'
testImplementation 'app.cash.turbine:turbine:0.11.0'
testImplementation Dependencies.mavenCentral.kotlinSerializationJson
@ -28,7 +28,7 @@ dependencies {
testImplementation project(":matrix:services:crypto")
testImplementation rootProject.files("external/jolm.jar")
testImplementation 'org.json:json:20220320'
testImplementation 'org.json:json:20220924'
testImplementation Dependencies.mavenCentral.ktorJava
testImplementation Dependencies.mavenCentral.sqldelightInMemory

View File

@ -123,18 +123,20 @@ class SmokeTest {
alice.expectTextMessage(SharedState.sharedRoom, message2)
// Needs investigation
// val aliceSecondDevice = testMatrix(SharedState.alice, isTemp = true, withLogging = true).also { it.newlogin() }
// aliceSecondDevice.client.syncService().startSyncing().collectAsync {
// val message3 = "from alice to bob and alice's second device".from(SharedState.alice.roomMember)
// alice.sendTextMessage(SharedState.sharedRoom, message3.content, isEncrypted)
// aliceSecondDevice.expectTextMessage(SharedState.sharedRoom, message3)
// bob.expectTextMessage(SharedState.sharedRoom, message3)
//
// val message4 = "from alice's second device to bob and alice's first device".from(SharedState.alice.roomMember)
// aliceSecondDevice.sendTextMessage(SharedState.sharedRoom, message4.content, isEncrypted)
// alice.expectTextMessage(SharedState.sharedRoom, message4)
// bob.expectTextMessage(SharedState.sharedRoom, message4)
// }
val aliceSecondDevice = testMatrix(SharedState.alice, isTemp = true, withLogging = true).also { it.newlogin() }
aliceSecondDevice.client.syncService().startSyncing().collectAsync {
aliceSecondDevice.client.proxyDeviceService().waitForOneTimeKeysToBeUploaded()
val message3 = "from alice to bob and alice's second device".from(SharedState.alice.roomMember)
alice.sendTextMessage(SharedState.sharedRoom, message3.content, isEncrypted)
aliceSecondDevice.expectTextMessage(SharedState.sharedRoom, message3)
bob.expectTextMessage(SharedState.sharedRoom, message3)
val message4 = "from alice's second device to bob and alice's first device".from(SharedState.alice.roomMember)
aliceSecondDevice.sendTextMessage(SharedState.sharedRoom, message4.content, isEncrypted)
alice.expectTextMessage(SharedState.sharedRoom, message4)
bob.expectTextMessage(SharedState.sharedRoom, message4)
}
}
}

View File

@ -14,6 +14,7 @@ import app.dapk.st.matrix.crypto.RoomMembersProvider
import app.dapk.st.matrix.crypto.Verification
import app.dapk.st.matrix.crypto.cryptoService
import app.dapk.st.matrix.crypto.installCryptoService
import app.dapk.st.matrix.device.DeviceService
import app.dapk.st.matrix.device.deviceService
import app.dapk.st.matrix.device.installEncryptionService
import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory
@ -39,6 +40,7 @@ import test.impl.PrintingErrorTracking
import java.io.File
import java.time.Clock
import javax.imageio.ImageIO
import kotlin.coroutines.resume
object TestUsers {
@ -93,7 +95,9 @@ class TestMatrix(
).also {
it.install {
installAuthService(storeModule.credentialsStore())
installEncryptionService(storeModule.knownDevicesStore())
installEncryptionService(storeModule.knownDevicesStore()).proxy {
ProxyDeviceService(it)
}
val olmAccountStore = OlmPersistenceWrapper(storeModule.olmStore(), base64)
val olm = OlmWrapper(
@ -356,3 +360,22 @@ class JavaImageContentReader : ImageContentReader {
override fun inputStream(uri: String) = File(uri).inputStream()
}
class ProxyDeviceService(private val deviceService: DeviceService) : DeviceService by deviceService {
private var oneTimeKeysContinuation: (() -> Unit)? = null
override suspend fun uploadOneTimeKeys(oneTimeKeys: DeviceService.OneTimeKeys) {
deviceService.uploadOneTimeKeys(oneTimeKeys)
oneTimeKeysContinuation?.invoke()?.also { oneTimeKeysContinuation = null }
}
suspend fun waitForOneTimeKeysToBeUploaded() {
suspendCancellableCoroutine { continuation ->
oneTimeKeysContinuation = { continuation.resume(Unit) }
}
}
}
fun MatrixClient.proxyDeviceService() = this.deviceService() as ProxyDeviceService

View File

@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"@googleapis/androidpublisher": "^3.0.0",
"matrix-js-sdk": "^19.4.0",
"matrix-js-sdk": "^19.7.0",
"request": "^2.88.2"
}
},
@ -631,9 +631,9 @@
"integrity": "sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA=="
},
"node_modules/matrix-js-sdk": {
"version": "19.4.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-19.4.0.tgz",
"integrity": "sha512-B8Mm4jCsCHaMaChcdM3VhZDVKrn0nMSDtYvHmS15Iu8Pe0G4qmIpk2AoADBAL9U9yN3pCqvs3TDXaQhM8UxRRA==",
"version": "19.7.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-19.7.0.tgz",
"integrity": "sha512-mFN1LBmEpYHCH6II1F8o7y8zJr0kn1yX7ga7tRXHbLJAlBS4bAXRsEoAzdv6OrV8/dS325JlVUYQLHFHQWjYxg==",
"dependencies": {
"@babel/runtime": "^7.12.5",
"another-json": "^0.2.0",
@ -1448,9 +1448,9 @@
"integrity": "sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA=="
},
"matrix-js-sdk": {
"version": "19.4.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-19.4.0.tgz",
"integrity": "sha512-B8Mm4jCsCHaMaChcdM3VhZDVKrn0nMSDtYvHmS15Iu8Pe0G4qmIpk2AoADBAL9U9yN3pCqvs3TDXaQhM8UxRRA==",
"version": "19.7.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-19.7.0.tgz",
"integrity": "sha512-mFN1LBmEpYHCH6II1F8o7y8zJr0kn1yX7ga7tRXHbLJAlBS4bAXRsEoAzdv6OrV8/dS325JlVUYQLHFHQWjYxg==",
"requires": {
"@babel/runtime": "^7.12.5",
"another-json": "^0.2.0",

View File

@ -7,7 +7,7 @@
"private": true,
"dependencies": {
"@googleapis/androidpublisher": "^3.0.0",
"matrix-js-sdk": "^19.4.0",
"matrix-js-sdk": "^19.7.0",
"request": "^2.88.2"
}
}

View File

@ -56,6 +56,7 @@ export const release = async (github, version, applicationId, artifacts, config)
console.log(releaseResult.data.id)
console.log("Uploading universal apk...")
await github.rest.repos.uploadReleaseAsset({
owner: config.owner,
repo: config.repo,
@ -64,6 +65,15 @@ export const release = async (github, version, applicationId, artifacts, config)
data: fs.readFileSync(universalApkPath)
})
console.log("Uploading foss apk...")
await github.rest.repos.uploadReleaseAsset({
owner: config.owner,
repo: config.repo,
release_id: releaseResult.data.id,
name: `foss-signed-${version.name}.apk`,
data: fs.readFileSync(artifacts.fossApkPath)
})
console.log("Promoting beta draft release to live...")
await promoteDraftToLive(applicationId)

View File

@ -9,7 +9,7 @@ SIGNED=$WORKING_DIR/app-foss-release-signed.apk
ZIPALIGN=$(find "$ANDROID_HOME" -iname zipalign -print -quit)
APKSIGNER=$(find "$ANDROID_HOME" -iname apksigner -print -quit)
./gradlew clean assembleRelease -Pfoss -Punsigned --no-daemon --no-configuration-cache --no-build-cache
./gradlew assembleRelease -Pfoss -Punsigned --no-daemon --no-configuration-cache --no-build-cache
$ZIPALIGN -v -p 4 $UNSIGNED $ALIGNED_UNSIGNED

View File

@ -1,6 +1,6 @@
#! /bin/bash
./gradlew clean bundleRelease -Punsigned --no-daemon --no-configuration-cache --no-build-cache
./gradlew bundleRelease -Punsigned --no-daemon --no-configuration-cache --no-build-cache
WORKING_DIR=app/build/outputs/bundle/release
RELEASE_AAB=$WORKING_DIR/app-release.aab

View File

@ -1,4 +1,4 @@
{
"code": 18,
"name": "22/09/2022-V1"
"code": 19,
"name": "29/09/2022-V1"
}