Merge branch 'develop' into feature/fga/room_list_api

This commit is contained in:
ganfra 2023-06-27 10:47:50 +02:00
commit 34f5e0597a
113 changed files with 2259 additions and 132 deletions

View file

@ -1,7 +0,0 @@
<component name="ProjectDictionaryState">
<dictionary name="bmarty">
<words>
<w>homeserver</w>
</words>
</dictionary>
</component>

View file

@ -2,8 +2,10 @@
<dictionary name="shared">
<words>
<w>backstack</w>
<w>homeserver</w>
<w>kover</w>
<w>onboarding</w>
<w>showkase</w>
<w>textfields</w>
</words>
</dictionary>

View file

@ -44,6 +44,7 @@ import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView
import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.api.oidc.OidcActionFlow
import io.element.android.features.preferences.api.CacheService
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
@ -54,7 +55,9 @@ import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
@ -65,6 +68,7 @@ class RootFlowNode @AssistedInject constructor(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val authenticationService: MatrixAuthenticationService,
private val cacheService: CacheService,
private val matrixClientsHolder: MatrixClientsHolder,
private val presenter: RootPresenter,
private val bugReportEntryPoint: BugReportEntryPoint,
@ -88,11 +92,19 @@ class RootFlowNode @AssistedInject constructor(
private fun observeLoggedInState() {
authenticationService.isLoggedIn()
.distinctUntilChanged()
.onEach { isLoggedIn ->
Timber.v("isLoggedIn=$isLoggedIn")
.combine(
cacheService.cacheIndex().onEach {
Timber.v("cacheIndex=$it")
matrixClientsHolder.removeAll()
}
) { isLoggedIn, cacheIdx -> isLoggedIn to cacheIdx }
.onEach { pair ->
val isLoggedIn = pair.first
val cacheIndex = pair.second
Timber.v("isLoggedIn=$isLoggedIn, cacheIndex=$cacheIndex")
if (isLoggedIn) {
tryToRestoreLatestSession(
onSuccess = { switchToLoggedInFlow(it) },
onSuccess = { switchToLoggedInFlow(it, cacheIndex) },
onFailure = { switchToNotLoggedInFlow() }
)
} else {
@ -102,8 +114,8 @@ class RootFlowNode @AssistedInject constructor(
.launchIn(lifecycleScope)
}
private fun switchToLoggedInFlow(sessionId: SessionId) {
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId))
private fun switchToLoggedInFlow(sessionId: SessionId, cacheIndex: Int) {
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, cacheIndex))
}
private fun switchToNotLoggedInFlow() {
@ -163,7 +175,7 @@ class RootFlowNode @AssistedInject constructor(
object NotLoggedInFlow : NavTarget
@Parcelize
data class LoggedInFlow(val sessionId: SessionId) : NavTarget
data class LoggedInFlow(val sessionId: SessionId, val cacheIndex: Int) : NavTarget
@Parcelize
object BugReport : NavTarget
@ -235,8 +247,9 @@ class RootFlowNode @AssistedInject constructor(
}
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
val cacheIndex = cacheService.cacheIndex().first()
return attachChild {
backstack.newRoot(NavTarget.LoggedInFlow(sessionId))
backstack.newRoot(NavTarget.LoggedInFlow(sessionId, cacheIndex))
}
}
}

View file

@ -247,6 +247,7 @@ koverMerged {
excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*"
excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState"
excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState"
excludes += "io.element.android.features.location.api.MapState"
}
bound {
minValue = 90

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_data_usage">"Nezaznamenávame ani neprofilujeme žiadne osobné údaje"</string>
<string name="screen_analytics_prompt_settings">"Môžete to kedykoľvek vypnúť"</string>
<string name="screen_analytics_prompt_third_party_sharing">"Vaše údaje nebudeme zdieľať s tretími stranami"</string>
<string name="screen_analytics_prompt_title">"Pomôžte zlepšiť %1$s"</string>
</resources>

View file

@ -29,9 +29,9 @@ class FakeAnalyticsService(
didAskUserConsent: Boolean = false
): AnalyticsService {
private var isEnabledFlow = MutableStateFlow(isEnabled)
private var didAskUserConsentFlow = MutableStateFlow(didAskUserConsent)
var capturedEvents = mutableListOf<VectorAnalyticsEvent>()
private val isEnabledFlow = MutableStateFlow(isEnabled)
private val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent)
val capturedEvents = mutableListOf<VectorAnalyticsEvent>()
override fun getAvailableAnalyticsProviders(): List<AnalyticsProvider> = emptyList()

View file

@ -2,7 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Neuer Raum"</string>
<string name="screen_create_room_action_invite_people">"Freunde zu Element einladen"</string>
<string name="screen_create_room_add_people_title">"Personen einladen"</string>
<string name="screen_create_room_add_people_title">"Personen hinzufügen"</string>
<string name="screen_create_room_error_creating_room">"Beim Erstellen des Raums ist ein Fehler aufgetreten"</string>
<string name="screen_create_room_private_option_description">"Die Nachrichten in diesem Raum sind verschlüsselt. Die Verschlüsselung kann nicht nachträglich deaktiviert werden."</string>
<string name="screen_create_room_private_option_title">"Privater Raum (nur auf Einladung)"</string>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Nová miestnosť"</string>
<string name="screen_create_room_action_invite_people">"Pozvať priateľov na Element"</string>
<string name="screen_create_room_add_people_title">"Pozvať ľudí"</string>
<string name="screen_create_room_error_creating_room">"Pri vytváraní miestnosti došlo k chybe"</string>
<string name="screen_create_room_private_option_description">"Správy v tejto miestnosti sú šifrované. Šifrovanie už potom nie je možné vypnúť."</string>
<string name="screen_create_room_private_option_title">"Súkromná miestnosť (len pre pozvaných)"</string>
<string name="screen_create_room_public_option_description">"Správy nie sú šifrované a môže si ich prečítať ktokoľvek. Šifrovanie môžete zapnúť neskôr."</string>
<string name="screen_create_room_public_option_title">"Verejná miestnosť (ktokoľvek)"</string>
<string name="screen_create_room_room_name_label">"Názov miestnosti"</string>
<string name="screen_create_room_topic_label">"Téma (voliteľné)"</string>
<string name="screen_create_room_title">"Vytvoriť miestnosť"</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_decline_chat_title">"Odmietnuť pozvanie"</string>
<string name="screen_invites_empty_list">"Žiadne pozvánky"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) vás pozval/a"</string>
</resources>

View file

@ -23,7 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
class FakeSeenInvitesStore : SeenInvitesStore {
private var existing = MutableStateFlow(emptySet<RoomId>())
private val existing = MutableStateFlow(emptySet<RoomId>())
private var provided: Set<RoomId>? = null
fun publishRoomIds(invites: Set<RoomId>) {

View file

@ -0,0 +1,53 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
android {
namespace = "io.element.android.features.location.api"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(libs.dagger)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.network)
implementation(projects.libraries.core)
implementation(projects.libraries.uiStrings)
implementation(libs.maplibre)
implementation(libs.network.retrofit)
implementation(libs.maplibre.annotation)
implementation(libs.coil.compose)
implementation(libs.serialization.json)
implementation(libs.accompanist.permission)
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.truth)
testImplementation(projects.libraries.matrix.test)
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.api
/**
* Represents a location sample emitted by the device's location subsystem.
*/
data class Location(
val lat: Double,
val lon: Double,
val accuracy: Float,
)

View file

@ -0,0 +1,297 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.api
import android.annotation.SuppressLint
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.mapbox.mapboxsdk.Mapbox
import com.mapbox.mapboxsdk.camera.CameraPosition
import com.mapbox.mapboxsdk.camera.CameraUpdateFactory
import com.mapbox.mapboxsdk.geometry.LatLng
import com.mapbox.mapboxsdk.maps.MapView
import com.mapbox.mapboxsdk.maps.MapboxMap
import com.mapbox.mapboxsdk.maps.Style
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
import io.element.android.features.location.api.internal.buildTileServerUrl
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import timber.log.Timber
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/**
* Composable wrapper around MapLibre's [MapView].
*/
@SuppressLint("MissingPermission")
@Composable
fun MapView(
modifier: Modifier = Modifier,
mapState: MapState = rememberMapState(),
darkMode: Boolean = !ElementTheme.colors.isLight,
onLocationClick: () -> Unit,
) {
// When in preview, early return a Box with the received modifier preserving layout
if (LocalInspectionMode.current) {
@Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return.
Box(modifier = modifier)
return
}
val context = LocalContext.current
val mapView = remember {
Mapbox.getInstance(context)
MapView(context)
}
var mapRefs by remember { mutableStateOf<MapRefs?>(null) }
// Build map
LaunchedEffect(darkMode) {
mapView.awaitMap().let { map ->
map.uiSettings.apply {
isCompassEnabled = false
}
map.setStyle(buildTileServerUrl(darkMode = darkMode)) { style ->
mapRefs = MapRefs(
map = map,
symbolManager = SymbolManager(mapView, map, style).apply {
iconAllowOverlap = true
},
style = style
)
}
}
}
// Update state position when moving map
DisposableEffect(mapRefs) {
var listener: MapboxMap.OnCameraIdleListener? = null
mapRefs?.let { mapRefs ->
listener = MapboxMap.OnCameraIdleListener {
mapRefs.map.cameraPosition.target?.let { target ->
val position = MapState.CameraPosition(
lat = target.latitude,
lon = target.longitude,
zoom = mapRefs.map.cameraPosition.zoom
)
mapState.position = position
Timber.d("Camera moved to: $position")
}
}.apply {
mapRefs.map.addOnCameraIdleListener(this)
Timber.d("Added OnCameraIdleListener $this")
}
}
onDispose {
mapRefs?.let { mapRefs ->
listener?.let {
mapRefs.map.removeOnCameraIdleListener(it).apply {
Timber.d("Removed OnCameraIdleListener $it")
}
}
}
}
}
// Move map to given position when state has changed
LaunchedEffect(mapRefs, mapState.position) {
mapRefs?.map?.moveCamera(
CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder()
.target(LatLng(mapState.position.lat, mapState.position.lon))
.zoom(mapState.position.zoom).build()
)
)
Timber.d("Camera position updated to: ${mapState.position}")
}
// Draw pin
LaunchedEffect(mapRefs, mapState.location) {
mapRefs?.let { mapRefs ->
mapState.location?.let { location ->
context.getDrawable(R.drawable.pin)?.let { mapRefs.style.addImage("pin", it) }
mapRefs.symbolManager.create(
SymbolOptions()
.withLatLng(LatLng(location.lat, location.lon))
.withIconImage("pin")
.withIconSize(1.3f)
)
Timber.d("Shown pin at location: $location")
}
}
}
// Draw markers
LaunchedEffect(mapRefs, mapState.markers) {
mapRefs?.let { mapRefs ->
mapState.markers.forEachIndexed { index, marker ->
context.getDrawable(marker.drawable)?.let { mapRefs.style.addImage("marker_$index", it) }
mapRefs.symbolManager.create(
SymbolOptions()
.withLatLng(LatLng(marker.lat, marker.lon))
.withIconImage("marker_$index")
.withIconSize(1.0f)
)
Timber.d("Shown marker at location: $marker")
}
}
}
@Suppress("ModifierReused")
Box(modifier = modifier) {
AndroidView(factory = { mapView })
FloatingActionButton(
onClick = onLocationClick,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
) {
Icon(
imageVector = Icons.Filled.LocationOn,
contentDescription = null, // TODO
)
}
}
}
@Composable
fun rememberMapState(
position: MapState.CameraPosition = MapState.CameraPosition(lat = 0.0, lon = 0.0, zoom = 0.0),
location: Location? = null,
markers: ImmutableList<MapState.Marker> = emptyList<MapState.Marker>().toImmutableList(),
): MapState = remember {
MapState(
position = position,
location = location,
markers = markers,
)
} // TODO(Use remember saveable with Parcelable custom saver)
@Stable
class MapState(
position: CameraPosition, // The position of the camera, it's what will be shared
location: Location? = null, // The location retrieved by the location subsystem, if any.
markers: ImmutableList<Marker> = emptyList<Marker>().toImmutableList(), // The pin's location, if any.
) {
var position: CameraPosition by mutableStateOf(position)
var location: Location? by mutableStateOf(location)
var markers: ImmutableList<Marker> by mutableStateOf(markers)
override fun toString(): String {
return "MapState(position=$position, location=$location, markers=$markers)"
}
@Stable
data class CameraPosition(
val lat: Double,
val lon: Double,
val zoom: Double,
)
@Stable
data class Marker(
@DrawableRes val drawable: Int,
val lat: Double,
val lon: Double,
)
}
private class MapRefs(
val map: MapboxMap,
val symbolManager: SymbolManager,
val style: Style
)
/**
* A suspending function that provides an instance of [MapboxMap] from this [MapView]. This is
* an alternative to [MapView.getMapAsync] by using coroutines to obtain the [MapboxMap].
*
* Inspired from [com.google.maps.android.ktx.awaitMap]
*
* @return the [MapboxMap] instance
*/
private suspend inline fun MapView.awaitMap(): MapboxMap =
suspendCoroutine { continuation ->
getMapAsync {
continuation.resume(it)
}
}
@Preview
@Composable
fun MapViewLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun MapViewDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
MapView(
modifier = Modifier.size(400.dp),
mapState = rememberMapState(
position = MapState.CameraPosition(
lat = 0.0,
lon = 0.0,
zoom = 0.0,
),
location = Location(
lat = 0.0,
lon = 0.0,
accuracy = 0.0f,
),
markers = listOf(
MapState.Marker(
drawable = R.drawable.pin,
lat = 0.0,
lon = 0.0,
)
).toImmutableList()
),
onLocationClick = {},
)
}

View file

@ -0,0 +1,135 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.api
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.features.location.api.internal.StaticMapPlaceholder
import io.element.android.features.location.api.internal.buildStaticMapsApiUrl
import timber.log.Timber
/**
* Shows a static map image downloaded via a third party service's static maps API.
*/
@Composable
fun StaticMapView(
lat: Double,
lon: Double,
zoom: Double,
contentDescription: String?,
modifier: Modifier = Modifier,
darkMode: Boolean = !ElementTheme.colors.isLight,
) {
// Using BoxWithConstraints to:
// 1) Size the inner Image to the same Dp size of the outer BoxWithConstraints.
// 2) Request the static map image of the exact required size in Px to fill the AsyncImage.
BoxWithConstraints(
modifier = modifier,
contentAlignment = Alignment.Center
) {
var retryHash by remember { mutableStateOf(0) }
val painter = rememberAsyncImagePainter(
model = if (constraints.isZero) {
// Avoid building a URL if any of the size constraints is zero (else it will thrown an exception).
null
} else {
ImageRequest.Builder(LocalContext.current)
.data(
buildStaticMapsApiUrl(
lat = lat,
lon = lon,
desiredZoom = zoom,
desiredWidth = constraints.maxWidth,
desiredHeight = constraints.maxHeight,
darkMode = darkMode,
)
)
.size(width = constraints.maxWidth, height = constraints.maxHeight)
.setParameter("retry_hash", retryHash, memoryCacheKey = null)
.build()
}.apply {
Timber.d("Static map image request: ${this?.data}")
}
)
if (painter.state is AsyncImagePainter.State.Success) {
Image(
painter = painter,
contentDescription = contentDescription,
modifier = Modifier.size(width = maxWidth, height = maxHeight),
// The returned image can be smaller than the requested size due to the static maps API having
// a max width and height of 2048 px. See buildStaticMapsApiUrl() for more details.
// We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case.
contentScale = ContentScale.Fit,
)
Icon(
resourceId = R.drawable.pin,
contentDescription = null,
tint = Color.Unspecified
)
} else {
StaticMapPlaceholder(
showProgress = painter.state is AsyncImagePainter.State.Loading,
contentDescription = contentDescription,
modifier = Modifier.size(width = maxWidth, height = maxHeight),
darkMode = darkMode,
onLoadMapClick = { retryHash++ }
)
}
}
}
@Preview
@Composable
fun StaticMapViewLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun StaticMapViewDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
StaticMapView(
lat = 0.0,
lon = 0.0,
zoom = 0.0,
contentDescription = null,
modifier = Modifier.size(400.dp),
)
}

View file

@ -0,0 +1,80 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.api.internal
import kotlin.math.roundToInt
private const val API_KEY = "fU3vlMsMn4Jb6dnEIFsx"
private const val BASE_URL = "https://api.maptiler.com"
private const val LIGHT_MAP_ID = "9bc819c8-e627-474a-a348-ec144fe3d810"
private const val DARK_MAP_ID = "dea61faf-292b-4774-9660-58fcef89a7f3"
private const val STATIC_MAP_FORMAT = "webp"
private const val STATIC_MAP_SCALE = "" // Either "" (empty string) for normal image or "@2x" for retina images.
private const val STATIC_MAP_MAX_WIDTH_HEIGHT = 2048
private const val STATIC_MAP_MAX_ZOOM = 22.0
internal fun buildTileServerUrl(
darkMode: Boolean
): String = if (!darkMode) {
"$BASE_URL/maps/$LIGHT_MAP_ID/style.json?key=$API_KEY"
} else {
"$BASE_URL/maps/$DARK_MAP_ID/style.json?key=$API_KEY"
}
/**
* Builds a valid URL for maptiler.com static map api based on the given params.
*
* Coerces width and height to the API maximum of 2048 keeping the requested aspect ratio.
* Coerces zoom to the API maximum of 22.
*
* NB: This will throw if either width or height are <= 0. You need to handle this case upstream
* (hint: views can't have negative width or height but can have 0 width or height sometimes).
*/
internal fun buildStaticMapsApiUrl(
lat: Double,
lon: Double,
desiredZoom: Double,
desiredWidth: Int,
desiredHeight: Int,
darkMode: Boolean
): String {
require(desiredWidth > 0 && desiredHeight > 0) {
"Width ($desiredHeight) and height ($desiredHeight) must be > 0"
}
require(desiredZoom >= 0) { "Zoom ($desiredZoom) must be >= 0" }
val zoom = desiredZoom.coerceAtMost(STATIC_MAP_MAX_ZOOM) // API will error if outside 0-22 range.
val width: Int
val height: Int
if (desiredWidth <= STATIC_MAP_MAX_WIDTH_HEIGHT && desiredHeight <= STATIC_MAP_MAX_WIDTH_HEIGHT) {
width = desiredWidth
height = desiredHeight
} else {
val aspectRatio = desiredWidth.toDouble() / desiredHeight.toDouble()
if (desiredWidth >= desiredHeight) {
width = desiredWidth.coerceAtMost(STATIC_MAP_MAX_WIDTH_HEIGHT)
height = (width / aspectRatio).roundToInt()
} else {
height = desiredHeight.coerceAtMost(STATIC_MAP_MAX_WIDTH_HEIGHT)
width = (height * aspectRatio).roundToInt()
}
}
return if (!darkMode) {
"$BASE_URL/maps/$LIGHT_MAP_ID/static/${lon},${lat},${zoom}/${width}x${height}$STATIC_MAP_SCALE.$STATIC_MAP_FORMAT?key=$API_KEY"
} else {
"$BASE_URL/maps/$DARK_MAP_ID/static/${lon},${lat},${zoom}/${width}x${height}$STATIC_MAP_SCALE.$STATIC_MAP_FORMAT?key=$API_KEY"
}
}

View file

@ -0,0 +1,111 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.api.internal
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.features.location.api.R
import io.element.android.libraries.ui.strings.R as StringsR
@Composable
internal fun StaticMapPlaceholder(
showProgress: Boolean,
contentDescription: String?,
modifier: Modifier = Modifier,
darkMode: Boolean = !ElementTheme.colors.isLight,
onLoadMapClick: () -> Unit,
) {
Box(
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(
id = if (darkMode) R.drawable.blurred_map_dark
else R.drawable.blurred_map_light
),
contentDescription = contentDescription,
modifier = modifier,
contentScale = ContentScale.FillBounds,
)
if (showProgress) {
CircularProgressIndicator()
} else {
Box(
modifier = modifier.clickable(onClick = onLoadMapClick),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = null
)
Text(text = stringResource(id = StringsR.string.action_static_map_load))
}
}
}
}
}
@Preview
@Composable
fun StaticMapPlaceholderLightPreview(
@PreviewParameter(BooleanParameterProvider::class) values: Boolean
) = ElementPreviewLight { ContentToPreview(values) }
@Preview
@Composable
fun StaticMapPlaceholderDarkPreview(
@PreviewParameter(BooleanParameterProvider::class) values: Boolean
) = ElementPreviewDark { ContentToPreview(values) }
@Composable
private fun ContentToPreview(showProgress: Boolean) {
StaticMapPlaceholder(
showProgress = showProgress,
contentDescription = null,
modifier = Modifier.size(400.dp),
onLoadMapClick = {},
)
}
internal class BooleanParameterProvider : PreviewParameterProvider<Boolean> {
override val values: Sequence<Boolean>
get() = sequenceOf(true, false)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View file

@ -0,0 +1,23 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="50dp"
android:height="108dp"
android:viewportWidth="50"
android:viewportHeight="108">
<group>
<clip-path
android:pathData="M0,0h50v108h-50z"/>
<path
android:pathData="M25,54L18.94,48L31.06,48L25,54Z"
android:fillColor="#1B1D22"/>
<path
android:pathData="M25,25m-25,0a25,25 0,1 1,50 0a25,25 0,1 1,-50 0"
android:fillColor="#1B1D22"/>
<group>
<clip-path
android:pathData="M13,13h24v24h-24z"/>
<path
android:pathData="M25,13C20.36,13 16.6,16.86 16.6,21.63C16.6,26.77 21.9,33.86 24.09,36.56C24.57,37.15 25.44,37.15 25.92,36.56C28.1,33.86 33.4,26.77 33.4,21.63C33.4,16.86 29.64,13 25,13ZM25,24.71C23.34,24.71 22,23.33 22,21.63C22,19.93 23.34,18.55 25,18.55C26.66,18.55 28,19.93 28,21.63C28,23.33 26.66,24.71 25,24.71Z"
android:fillColor="#ffffff"/>
</group>
</group>
</vector>

View file

@ -0,0 +1,71 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.api.internal
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.internal.buildStaticMapsApiUrl
import org.junit.Test
class BuildStaticMapsApiUrlTest {
@Test
fun `buildStaticMapsApiUrl builds light mode url`() {
assertThat(
buildStaticMapsApiUrl(
lat = 1.234,
lon = 5.678,
desiredZoom = 1.2,
desiredWidth = 100,
desiredHeight = 200,
darkMode = false
)
).isEqualTo(
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200.webp?key=fU3vlMsMn4Jb6dnEIFsx"
)
}
@Test
fun `buildStaticMapsApiUrl builds dark mode url`() {
assertThat(
buildStaticMapsApiUrl(
lat = 1.234,
lon = 5.678,
desiredZoom = 1.2,
desiredWidth = 100,
desiredHeight = 200,
darkMode = true
)
).isEqualTo(
"https://api.maptiler.com/maps/dea61faf-292b-4774-9660-58fcef89a7f3/static/5.678,1.234,1.2/100x200.webp?key=fU3vlMsMn4Jb6dnEIFsx"
)
}
@Test
fun `buildStaticMapsApiUrl coerces zoom at 22 and width and height at max 2048 keeping aspect ratio`() {
assertThat(
buildStaticMapsApiUrl(
lat = 1.234,
lon = 5.678,
desiredZoom = 100.0,
desiredWidth = 8192,
desiredHeight = 4096,
darkMode = false
)
).isEqualTo(
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,22.0/2048x1024.webp?key=fU3vlMsMn4Jb6dnEIFsx"
)
}
}

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "io.element.android.features.location.fake"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(libs.dagger)
api(projects.features.location.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.di)
implementation(projects.libraries.network)
implementation(projects.libraries.core)
implementation(libs.maplibre)
implementation(libs.network.retrofit)
implementation(libs.maplibre.annotation)
implementation(libs.coil.compose)
implementation(libs.serialization.json)
implementation(libs.accompanist.permission)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.truth)
testImplementation(projects.libraries.matrix.test)
}

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.fake
import io.element.android.features.location.api.Location
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
fun fakeLocationUpdatesFlow(): Flow<Location> = flow {
while (true) {
delay(1_000)
emit(aLocation())
}
}
private fun aLocation() = Location(
lat = 51.49404,
lon = -0.25484,
accuracy = 5f
)

View file

@ -0,0 +1,54 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
android {
namespace = "io.element.android.features.location.impl"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(libs.dagger)
api(projects.features.location.api)
implementation(projects.libraries.di)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.network)
implementation(projects.libraries.core)
implementation(libs.maplibre)
implementation(libs.network.retrofit)
implementation(libs.maplibre.annotation)
implementation(libs.coil.compose)
implementation(libs.serialization.json)
implementation(libs.accompanist.permission)
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.truth)
testImplementation(projects.libraries.matrix.test)
}

View file

@ -0,0 +1,21 @@
<!--
~ Copyright (c) 2023 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
</manifest>

View file

@ -0,0 +1,96 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl
import android.Manifest
import android.content.Context
import android.location.LocationManager
import androidx.annotation.RequiresPermission
import androidx.core.content.getSystemService
import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import androidx.core.location.LocationRequestCompat
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.features.location.api.Location
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
/**
* Returns a cold [Flow] that, once collected, emits [Location] updates every second.
*/
@RequiresPermission(
anyOf = [
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
]
)
fun locationUpdatesFlow(
context: Context,
coroutineDispatchers: CoroutineDispatchers,
): Flow<Location> = callbackFlow {
val locationManager: LocationManager = checkNotNull(context.getSystemService())
val provider = locationManager.bestAvailableProvider()
// Try to eagerly emit the last known location as fast as possible
locationManager.getLastKnownLocation(provider)?.let { location ->
trySendBlocking(
Location(
lat = location.latitude,
lon = location.longitude,
accuracy = location.accuracy
)
)
}
val locationListener = LocationListenerCompat { location ->
trySendBlocking(
Location(
lat = location.latitude,
lon = location.longitude,
accuracy = location.accuracy
)
)
}
LocationManagerCompat.requestLocationUpdates(
locationManager,
provider,
buildLocationRequest(),
coroutineDispatchers.io.asExecutor(),
locationListener,
)
awaitClose {
LocationManagerCompat.removeUpdates(locationManager, locationListener)
}
}
private fun LocationManager.bestAvailableProvider(): String =
checkNotNull(getProviders(true).maxByOrNull { providerPriority(it) }) {
"No location provider available"
}
private fun providerPriority(provider: String): Int = when (provider) {
LocationManager.FUSED_PROVIDER -> 4
LocationManager.GPS_PROVIDER -> 3
LocationManager.NETWORK_PROVIDER -> 2
LocationManager.PASSIVE_PROVIDER -> 1
else -> 0
}
private fun buildLocationRequest() = LocationRequestCompat.Builder(1_000).apply {
setMinUpdateIntervalMillis(1_000)
}.build()

View file

@ -13,7 +13,7 @@
<string name="screen_change_account_provider_other">"Andere"</string>
<string name="screen_change_account_provider_subtitle">"Verwende einen anderen Kontoanbieter, z. B. deinen eigenen privaten Server oder ein Arbeitskonto."</string>
<string name="screen_change_account_provider_title">"Kontoanbieter ändern"</string>
<string name="screen_change_server_error_invalid_homeserver">"Wir konnten diesen Homeserver nicht erreichen. Bitte überprüfen Sie, ob Sie die Homeserver-URL korrekt eingegeben haben. Wenn die URL korrekt ist, wenden Sie sich an Ihren Homeserver-Administrator, um weitere Hilfe zu erhalten."</string>
<string name="screen_change_server_error_invalid_homeserver">"Wir konnten diesen Homeserver nicht erreichen. Bitte überprüfe, dass du die Homeserver-URL korrekt eingegeben hast. Wenn die URL korrekt ist, wende dich an deinen Homeserver-Administrator für weitere Hilfe."</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Dieser Server unterstützt derzeit keine Sliding Sync."</string>
<string name="screen_change_server_form_header">"Homeserver-URL"</string>
<string name="screen_change_server_form_notice">"Du kannst dich nur mit einem existierenden Server verbinden, der Sliding Sync unterstützt. Dein Homeserver-Administrator muss es konfigurieren. %1$s"</string>

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_account_provider_continue">"Pokračovať"</string>
<string name="screen_account_provider_form_hint">"Adresa domovského servera"</string>
<string name="screen_change_server_form_header">"Adresa URL domovského servera"</string>
<string name="screen_change_server_subtitle">"Aká je adresa vášho servera?"</string>
<string name="screen_login_error_deactivated_account">"Tento účet bol deaktivovaný."</string>
<string name="screen_login_error_invalid_credentials">"Nesprávne používateľské meno a/alebo heslo"</string>
<string name="screen_login_form_header">"Zadajte svoje údaje"</string>
<string name="screen_login_server_header">"Kde žijú vaše rozhovory"</string>
<string name="screen_login_title">"Vitajte späť!"</string>
<string name="screen_login_title_with_homeserver">"Prihlásiť sa do %1$s"</string>
<string name="screen_server_confirmation_change_server">"Zmeniť poskytovateľa účtu"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Súkromný server pre zamestnancov spoločnosti Element."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix je otvorená sieť pre bezpečnú a decentralizovanú komunikáciu."</string>
<string name="screen_server_confirmation_title_login">"Chystáte sa prihlásiť do %1$s"</string>
<string name="screen_server_confirmation_title_register">"Chystáte sa vytvoriť účet na %1$s"</string>
<string name="screen_change_server_submit">"Pokračovať"</string>
<string name="screen_change_server_title">"Vyberte svoj server"</string>
<string name="screen_login_password_hint">"Heslo"</string>
<string name="screen_login_submit">"Pokračovať"</string>
<string name="screen_login_username_hint">"Používateľské meno"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Ste si istí, že sa chcete odhlásiť?"</string>
<string name="screen_signout_confirmation_dialog_title">"Odhlásiť sa"</string>
<string name="screen_signout_in_progress_dialog_content">"Prebieha odhlasovanie…"</string>
<string name="screen_signout_confirmation_dialog_submit">"Odhlásiť sa"</string>
<string name="screen_signout_preference_item">"Odhlásiť sa"</string>
</resources>

View file

@ -58,7 +58,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
@ -110,7 +110,7 @@ class MessagesPresenter @AssistedInject constructor(
val networkConnectionStatus by networkMonitor.connectivity.collectAsState()
val snackbarMessage = handleSnackbarMessage(snackbarDispatcher)
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
LaunchedEffect(syncUpdateFlow) {
roomAvatar.value =

View file

@ -21,7 +21,7 @@ import android.net.Uri
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor
import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.androidutils.file.getFileName
import io.element.android.libraries.androidutils.file.getFileSize
import io.element.android.libraries.androidutils.file.getMimeType

View file

@ -35,7 +35,7 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile
import kotlinx.coroutines.CoroutineScope
@ -66,7 +66,7 @@ class MediaViewerPresenter @AssistedInject constructor(
val localMedia: MutableState<Async<LocalMedia>> = remember {
mutableStateOf(Async.Uninitialized)
}
val snackbarMessage = handleSnackbarMessage(snackbarDispatcher)
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
localMediaActions.Configure()
DisposableEffect(loadMediaTrigger) {
coroutineScope.downloadMedia(mediaFile, localMedia)

View file

@ -25,8 +25,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor
import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter
import io.element.android.features.messages.impl.timeline.util.toHtmlDocument
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType

View file

@ -10,10 +10,12 @@
<string name="screen_room_attachment_source_camera_video">"Natočit video"</string>
<string name="screen_room_attachment_source_files">"Příloha"</string>
<string name="screen_room_attachment_source_gallery">"Knihovna fotografií a videí"</string>
<string name="screen_room_attachment_source_location">"Poloha"</string>
<string name="screen_room_error_failed_retrieving_user_details">"Nepodařilo se načíst údaje o uživateli"</string>
<string name="screen_room_invite_again_alert_message">"Chtěli byste je pozvat zpět?"</string>
<string name="screen_room_invite_again_alert_title">"V tomto chatu jste sami"</string>
<string name="screen_room_no_permission_to_post">"Nemáte oprávnění vkládat příspěvky do této místnosti"</string>
<string name="screen_room_message_copied">"Zpráva zkopírována"</string>
<string name="screen_room_no_permission_to_post">"Nemáte oprávnění zveřejňovat příspěvky v této místnosti"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Odeslat znovu"</string>
<string name="screen_room_retry_send_menu_title">"Vaši zprávu se nepodařilo odeslat"</string>
<string name="screen_room_error_failed_processing_media">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="room_timeline_state_changes">
<item quantity="one">"%1$d zmena miestnosti"</item>
<item quantity="few">"%1$d zmeny miestnosti"</item>
<item quantity="other">"%1$d zmien miestnosti"</item>
</plurals>
<string name="screen_room_attachment_source_camera">"Kamera"</string>
<string name="screen_room_attachment_source_camera_photo">"Odfotiť"</string>
<string name="screen_room_attachment_source_camera_video">"Nahrať video"</string>
<string name="screen_room_attachment_source_files">"Príloha"</string>
<string name="screen_room_attachment_source_gallery">"Knižnica fotografií a videí"</string>
<string name="screen_room_attachment_source_location">"Poloha"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Odoslať znova"</string>
<string name="screen_room_retry_send_menu_remove_action">"Odstrániť"</string>
</resources>

View file

@ -9,6 +9,7 @@
<string name="screen_room_attachment_source_camera_video">"Record a video"</string>
<string name="screen_room_attachment_source_files">"Attachment"</string>
<string name="screen_room_attachment_source_gallery">"Photo &amp; Video Library"</string>
<string name="screen_room_attachment_source_location">"Location"</string>
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
<string name="screen_room_invite_again_alert_message">"Would you like to invite them back?"</string>
<string name="screen_room_invite_again_alert_title">"You are alone in this chat"</string>

View file

@ -32,7 +32,7 @@ import io.element.android.features.messages.impl.timeline.factories.virtual.Time
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractorWithoutValidation
import io.element.android.features.messages.timeline.FakeFileSizeFormatter
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem

View file

@ -4,6 +4,7 @@
<string name="screen_onboarding_sign_in_with_qr_code">"Přihlásit se pomocí QR kódu"</string>
<string name="screen_onboarding_sign_up">"Vytvořit účet"</string>
<string name="screen_onboarding_subtitle">"Komunikujte a spolupracujte bezpečně"</string>
<string name="screen_onboarding_welcome_message">"Vítejte u dosud nejrychlejšího Elementu. Vylepšený pro rychlost a jednoduchost."</string>
<string name="screen_onboarding_welcome_subtitle">"Vítejte v %1$s. Vylepšený, pro rychlost a jednoduchost."</string>
<string name="screen_onboarding_welcome_title">"Buďte ve svém živlu"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_onboarding_sign_up">"Vytvoriť účet"</string>
</resources>

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.api
import kotlinx.coroutines.flow.Flow
interface CacheService {
/**
* Returns a flow of the current cache index, can let the app to know when the
* cache has been cleared, for instance to restart the app.
* Will be a flow of Int, starting from 0, and incrementing each time the cache is cleared.
*/
fun cacheIndex(): Flow<Int>
}

View file

@ -32,6 +32,7 @@ anvil {
dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
@ -39,6 +40,7 @@ dependencies {
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.featureflag.ui)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.network)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
implementation(projects.features.rageshake.api)
@ -47,6 +49,7 @@ dependencies {
implementation(projects.features.logout.api)
implementation(libs.datetime)
implementation(libs.accompanist.placeholder)
implementation(libs.coil.compose)
api(projects.features.preferences.api)
ksp(libs.showkase.processor)
@ -62,6 +65,7 @@ dependencies {
testImplementation(projects.features.logout.impl)
testImplementation(projects.features.analytics.test)
testImplementation(projects.features.analytics.impl)
testImplementation(projects.tests.testutils)
androidTestImplementation(libs.test.junitext)
}

View file

@ -14,25 +14,26 @@
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.util
package io.element.android.features.preferences.impl
import android.content.Context
import android.text.format.Formatter
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.preferences.api.CacheService
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
interface FileSizeFormatter {
/**
* Formats a content size to be in the form of bytes, kilobytes, megabytes, etc.
*/
fun format(fileSize: Long): String
}
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class AndroidFileSizeFormatter @Inject constructor(@ApplicationContext private val context: Context) : FileSizeFormatter {
override fun format(fileSize: Long): String {
return Formatter.formatShortFileSize(context, fileSize)
class DefaultCacheService @Inject constructor() : CacheService {
private val cacheIndexState = MutableStateFlow(0)
override fun cacheIndex(): Flow<Int> {
return cacheIndexState
}
fun incrementCacheIndex() {
cacheIndexState.value++
}
}

View file

@ -20,4 +20,5 @@ import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
sealed interface DeveloperSettingsEvents {
data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : DeveloperSettingsEvents
object ClearCache: DeveloperSettingsEvents
}

View file

@ -18,12 +18,18 @@ package io.element.android.features.preferences.impl.developer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshots.SnapshotStateMap
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -36,6 +42,8 @@ import javax.inject.Inject
class DeveloperSettingsPresenter @Inject constructor(
private val featureFlagService: FeatureFlagService,
private val computeCacheSizeUseCase: ComputeCacheSizeUseCase,
private val clearCacheUseCase: ClearCacheUseCase,
) : Presenter<DeveloperSettingsState> {
@Composable
@ -47,6 +55,12 @@ class DeveloperSettingsPresenter @Inject constructor(
val enabledFeatures = remember {
mutableStateMapOf<String, Boolean>()
}
val cacheSize = remember {
mutableStateOf<Async<String>>(Async.Uninitialized)
}
val clearCacheAction = remember {
mutableStateOf<Async<Unit>>(Async.Uninitialized)
}
LaunchedEffect(Unit) {
FeatureFlags.values().forEach { feature ->
features[feature.key] = feature
@ -55,6 +69,10 @@ class DeveloperSettingsPresenter @Inject constructor(
}
val featureUiModels = createUiModels(features, enabledFeatures)
val coroutineScope = rememberCoroutineScope()
// Compute cache size each time the clear cache action value is changed
LaunchedEffect(clearCacheAction.value) {
computeCacheSize(cacheSize)
}
fun handleEvents(event: DeveloperSettingsEvents) {
when (event) {
@ -64,11 +82,14 @@ class DeveloperSettingsPresenter @Inject constructor(
event.feature,
event.isEnabled
)
DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction)
}
}
return DeveloperSettingsState(
features = featureUiModels.toImmutableList(),
cacheSize = cacheSize.value,
clearCacheAction = clearCacheAction.value,
eventSink = ::handleEvents
)
}
@ -103,6 +124,18 @@ class DeveloperSettingsPresenter @Inject constructor(
enabledFeatures[featureUiModel.key] = enabled
}
}
private fun CoroutineScope.computeCacheSize(cacheSize: MutableState<Async<String>>) = launch {
suspend {
computeCacheSizeUseCase()
}.execute(cacheSize)
}
private fun CoroutineScope.clearCache(clearCacheAction: MutableState<Async<Unit>>) = launch {
suspend {
clearCacheUseCase()
}.execute(clearCacheAction)
}
}

View file

@ -16,10 +16,13 @@
package io.element.android.features.preferences.impl.developer
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import kotlinx.collections.immutable.ImmutableList
data class DeveloperSettingsState(
data class DeveloperSettingsState constructor(
val features: ImmutableList<FeatureUiModel>,
val cacheSize: Async<String>,
val clearCacheAction: Async<Unit>,
val eventSink: (DeveloperSettingsEvents) -> Unit
)

View file

@ -17,16 +17,20 @@
package io.element.android.features.preferences.impl.developer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList
open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSettingsState> {
override val values: Sequence<DeveloperSettingsState>
get() = sequenceOf(
aDeveloperSettingsState(),
aDeveloperSettingsState().copy(clearCacheAction = Async.Loading()),
)
}
fun aDeveloperSettingsState() = DeveloperSettingsState(
features = aFeatureUiModelList(),
cacheSize = Async.Success("1.2 MB"),
clearCacheAction = Async.Uninitialized,
eventSink = {}
)

View file

@ -16,11 +16,15 @@
package io.element.android.features.preferences.impl.developer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.isLoading
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.components.preferences.PreferenceView
@ -52,6 +56,20 @@ fun DeveloperSettingsView(
onClick = onOpenShowkase
)
}
val cache = state.cacheSize
PreferenceCategory(title = "Cache") {
PreferenceText(
title = "Clear cache",
icon = Icons.Default.Delete,
currentValue = cache.dataOrNull(),
loadingCurrentValue = state.cacheSize.isLoading() || state.clearCacheAction.isLoading(),
onClick = {
if (state.clearCacheAction.isLoading().not()) {
state.eventSink(DeveloperSettingsEvents.ClearCache)
}
}
)
}
}
}

View file

@ -0,0 +1,62 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalCoilApi::class)
package io.element.android.features.preferences.impl.tasks
import android.content.Context
import coil.Coil
import coil.annotation.ExperimentalCoilApi
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.preferences.impl.DefaultCacheService
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import javax.inject.Inject
import javax.inject.Provider
interface ClearCacheUseCase {
suspend operator fun invoke()
}
@ContributesBinding(SessionScope::class)
class DefaultClearCacheUseCase @Inject constructor(
@ApplicationContext private val context: Context,
private val matrixClient: MatrixClient,
private val coroutineDispatchers: CoroutineDispatchers,
private val defaultCacheIndexProvider: DefaultCacheService,
private val okHttpClient: Provider<OkHttpClient>,
) : ClearCacheUseCase {
override suspend fun invoke() = withContext(coroutineDispatchers.io) {
// Clear Matrix cache
matrixClient.clearCache()
// Clear Coil cache
Coil.imageLoader(context).let {
it.diskCache?.clear()
it.memoryCache?.clear()
}
// Clear OkHttp cache
okHttpClient.get().cache?.delete()
// Clear app cache
context.cacheDir.deleteRecursively()
// Ensure the app is restarted
defaultCacheIndexProvider.incrementCacheIndex()
}
}

View file

@ -0,0 +1,48 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.tasks
import android.content.Context
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.androidutils.file.getSizeOfFiles
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.coroutines.withContext
import javax.inject.Inject
interface ComputeCacheSizeUseCase {
suspend operator fun invoke(): String
}
@ContributesBinding(SessionScope::class)
class DefaultComputeCacheSizeUseCase @Inject constructor(
@ApplicationContext private val context: Context,
private val matrixClient: MatrixClient,
private val coroutineDispatchers: CoroutineDispatchers,
private val fileSizeFormatter: FileSizeFormatter,
) : ComputeCacheSizeUseCase {
override suspend fun invoke(): String = withContext(coroutineDispatchers.io) {
var cumulativeSize = 0L
cumulativeSize += matrixClient.getCacheSize()
// - 4096 to not include the size fo the folder
cumulativeSize += (context.cacheDir.getSizeOfFiles() - 4096).coerceAtLeast(0)
fileSizeFormatter.format(cumulativeSize)
}
}

View file

@ -20,6 +20,9 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import kotlinx.coroutines.test.runTest
@ -29,13 +32,17 @@ class DeveloperSettingsPresenterTest {
@Test
fun `present - ensures initial state is correct`() = runTest {
val presenter = DeveloperSettingsPresenter(
FakeFeatureFlagService()
FakeFeatureFlagService(),
FakeComputeCacheSizeUseCase(),
FakeClearCacheUseCase(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.features).isEmpty()
assertThat(initialState.clearCacheAction).isEqualTo(Async.Uninitialized)
assertThat(initialState.cacheSize).isEqualTo(Async.Uninitialized)
cancelAndIgnoreRemainingEvents()
}
}
@ -43,7 +50,9 @@ class DeveloperSettingsPresenterTest {
@Test
fun `present - ensures feature list is loaded`() = runTest {
val presenter = DeveloperSettingsPresenter(
FakeFeatureFlagService()
FakeFeatureFlagService(),
FakeComputeCacheSizeUseCase(),
FakeClearCacheUseCase(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -58,7 +67,9 @@ class DeveloperSettingsPresenterTest {
@Test
fun `present - ensures state is updated when enabled feature event is triggered`() = runTest {
val presenter = DeveloperSettingsPresenter(
FakeFeatureFlagService()
FakeFeatureFlagService(),
FakeComputeCacheSizeUseCase(),
FakeClearCacheUseCase(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -74,4 +85,28 @@ class DeveloperSettingsPresenterTest {
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - clear cache`() = runTest {
val clearCacheUseCase = FakeClearCacheUseCase()
val presenter = DeveloperSettingsPresenter(
FakeFeatureFlagService(),
FakeComputeCacheSizeUseCase(),
clearCacheUseCase,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(clearCacheUseCase.executeHasBeenCalled).isFalse()
initialState.eventSink(DeveloperSettingsEvents.ClearCache)
val stateAfterEvent = awaitItem()
assertThat(stateAfterEvent.clearCacheAction).isInstanceOf(Async.Loading::class.java)
skipItems(1)
assertThat(awaitItem().clearCacheAction).isInstanceOf(Async.Success::class.java)
assertThat(clearCacheUseCase.executeHasBeenCalled).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
}

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.tasks
import io.element.android.tests.testutils.simulateLongTask
class FakeClearCacheUseCase : ClearCacheUseCase {
var executeHasBeenCalled = false
private set
override suspend fun invoke() = simulateLongTask {
executeHasBeenCalled = true
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.tasks
import io.element.android.tests.testutils.simulateLongTask
class FakeComputeCacheSizeUseCase : ComputeCacheSizeUseCase {
override suspend fun invoke() = simulateLongTask {
"O kB"
}
}

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rageshake_detection_dialog_content">"Zdá sa, že zúrivo trasiete telefónom. Chcete otvoriť obrazovku s nahlásením chýb?"</string>
</resources>

View file

@ -52,6 +52,7 @@ import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.util.Locale
import javax.inject.Inject
import javax.inject.Provider
/**
* BugReporter creates and sends the bug reports.
@ -62,7 +63,7 @@ class DefaultBugReporter @Inject constructor(
private val screenshotHolder: ScreenshotHolder,
private val crashDataStore: CrashDataStore,
private val coroutineDispatchers: CoroutineDispatchers,
private val okHttpClient: OkHttpClient,
private val okHttpClient: Provider<OkHttpClient>,
/*
private val activeSessionHolder: ActiveSessionHolder,
private val versionProvider: VersionProvider,
@ -339,7 +340,7 @@ class DefaultBugReporter @Inject constructor(
// trigger the request
try {
mBugReportCall = okHttpClient.newCall(request)
mBugReportCall = okHttpClient.get().newCall(request)
response = mBugReportCall!!.execute()
responseCode = response.code
} catch (e: Exception) {

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bug_report_attach_screenshot">"Priložiť snímku obrazovky"</string>
<string name="screen_bug_report_edit_screenshot">"Upraviť snímku obrazovky"</string>
<string name="screen_bug_report_editor_placeholder">"Popíšte chybu…"</string>
<string name="screen_bug_report_editor_supporting">"Ak je to možné, napíšte popis v angličtine."</string>
<string name="screen_bug_report_include_screenshot">"Odoslať snímku obrazovky"</string>
</resources>

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="screen_room_member_list_header_title">
<item quantity="one">"1 osoba"</item>
<item quantity="few">"%1$d ľudia"</item>
<item quantity="other">"%1$d ľudí"</item>
</plurals>
<string name="screen_room_details_add_topic_title">"Pridať tému"</string>
<string name="screen_room_details_edit_room_title">"Upraviť miestnosť"</string>
<string name="screen_room_details_edition_error">"Nepodarilo sa nám aktualizovať všetky informácie o tejto miestnosti."</string>
<string name="screen_room_details_edition_error_title">"Nepodarilo sa aktualizovať miestnosť"</string>
<string name="screen_room_details_encryption_enabled_title">"Šifrovanie správ je zapnuté"</string>
<string name="screen_room_details_invite_people_title">"Pozvať ľudí"</string>
<string name="screen_room_details_notification_title">"Oznámenie"</string>
<string name="screen_room_details_room_name_label">"Názov miestnosti"</string>
<string name="screen_room_details_share_room_title">"Zdieľať miestnosť"</string>
<string name="screen_room_details_updating_room">"Aktualizácia miestnosti…"</string>
<string name="screen_room_member_list_pending_header_title">"Čaká sa"</string>
<string name="screen_room_member_list_room_members_header_title">"Členovia miestnosti"</string>
<string name="screen_dm_details_block_alert_action">"Zablokovať"</string>
<string name="screen_dm_details_block_user">"Zablokovať používateľa"</string>
<string name="screen_dm_details_unblock_alert_action">"Odblokovať"</string>
<string name="screen_dm_details_unblock_user">"Odblokovať používateľa"</string>
<string name="screen_room_details_leave_room_title">"Opustiť miestnosť"</string>
<string name="screen_room_details_people_title">"Ľudia"</string>
<string name="screen_room_details_security_title">"Bezpečnosť"</string>
<string name="screen_room_details_topic_title">"Téma"</string>
</resources>

View file

@ -38,7 +38,7 @@ import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
@ -135,7 +135,7 @@ class RoomListPresenter @Inject constructor(
filteredRoomSummaries.value = updateFilteredRoomSummaries(mappedRoomSummaries.value, filter)
}
val snackbarMessage = handleSnackbarMessage(snackbarDispatcher)
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
return RoomListState(
matrixUser = matrixUser.value,

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_roomlist_a11y_create_message">"Vytvorte novú konverzáciu alebo miestnosť"</string>
<string name="screen_roomlist_main_space_title">"Všetky konverzácie"</string>
<string name="session_verification_banner_message">"Vyzerá to tak, že používate nové zariadenie. Overte, či ste to vy, aby ste mali prístup k zašifrovaným správam."</string>
<string name="session_verification_banner_title">"Získajte prístup k histórii vašich správ"</string>
</resources>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_session_verification_cancelled_subtitle">"Zdá sa, že niečo nie je v poriadku. Časový limit žiadosti vypršal alebo bola žiadosť zamietnutá."</string>
<string name="screen_session_verification_compare_emojis_subtitle">"Skontrolujte, či sa emotikony uvedené nižšie zhodujú s emotikonmi zobrazenými vo vašej druhej relácii."</string>
<string name="screen_session_verification_compare_emojis_title">"Porovnajte emotikony"</string>
<string name="screen_session_verification_complete_subtitle">"Vaša nová relácia je teraz overená. Má prístup k vašim zašifrovaným správam a ostatní používatelia ju budú vidieť ako dôveryhodnú."</string>
<string name="screen_session_verification_open_existing_session_subtitle">"Dokážte, že ste to vy, aby ste získali prístup k histórii vašich zašifrovaných správ."</string>
<string name="screen_session_verification_open_existing_session_title">"Otvoriť existujúcu reláciu"</string>
<string name="screen_session_verification_positive_button_canceled">"Zopakovať overenie"</string>
<string name="screen_session_verification_positive_button_initial">"Som pripravený/á"</string>
<string name="screen_session_verification_positive_button_verifying_ongoing">"Čaká sa na zhodu"</string>
<string name="screen_session_verification_request_accepted_subtitle">"Porovnajte jedinečné emotikony a uistite sa, že sú zobrazené v rovnakom poradí."</string>
<string name="screen_session_verification_they_dont_match">"Nezhodujú sa"</string>
<string name="screen_session_verification_they_match">"Zhodujú sa"</string>
<string name="screen_session_verification_waiting_to_accept_subtitle">"Ak chcete pokračovať, prijmite žiadosť o spustenie procesu overenia vo vašej druhej relácii."</string>
<string name="screen_session_verification_waiting_to_accept_title">"Čaká sa na prijatie žiadosti"</string>
<string name="screen_session_verification_cancelled_title">"Overovanie zrušené"</string>
<string name="screen_session_verification_positive_button_ready">"Spustiť"</string>
</resources>

View file

@ -6,7 +6,7 @@
android_gradle_plugin = "8.0.2"
kotlin = "1.8.21"
ksp = "1.8.21-1.0.11"
molecule = "0.9.0"
molecule = "0.10.0"
# AndroidX
material = "1.9.0"
@ -39,8 +39,8 @@ datetime = "0.4.0"
serialization_json = "1.5.1"
showkase = "1.0.0-beta18"
jsoup = "1.16.1"
appyx = "1.2.0"
dependencycheck = "8.2.1"
appyx = "1.3.0"
dependencycheck = "8.3.1"
dependencyanalysis = "1.20.0"
stem = "2.3.0"
sqldelight = "1.5.5"
@ -142,7 +142,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.23"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.25"
sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
@ -155,6 +155,8 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0"
vanniktech_emoji = "com.vanniktech:emoji-google:0.16.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.1.0"
maplibre = "org.maplibre.gl:android-sdk:10.2.0"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:1.0.0"
# Analytics
posthog = "com.posthog.android:posthog:2.0.3"
@ -186,6 +188,7 @@ android_application = { id = "com.android.application", version.ref = "android_g
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
kotlin_android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin_jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin_serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
anvil = { id = "com.squareup.anvil", version.ref = "anvil" }

View file

@ -17,9 +17,11 @@
package io.element.android.libraries.androidutils.file
import android.content.Context
import androidx.annotation.WorkerThread
import io.element.android.libraries.core.data.tryOrNull
import timber.log.Timber
import java.io.File
import java.util.Locale
import java.util.UUID
fun File.safeDelete() {
@ -52,3 +54,99 @@ fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null):
val suffix = extension?.let { ".$extension" }
return File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() }
}
// Implementation should return true in case of success
typealias ActionOnFile = (file: File) -> Boolean
/* ==========================================================================================
* Log
* ========================================================================================== */
fun lsFiles(context: Context) {
Timber.v("Content of cache dir:")
recursiveActionOnFile(context.cacheDir, ::logAction)
Timber.v("Content of files dir:")
recursiveActionOnFile(context.filesDir, ::logAction)
}
private fun logAction(file: File): Boolean {
if (file.isDirectory) {
Timber.v(file.toString())
} else {
Timber.v("$file ${file.length()} bytes")
}
return true
}
/* ==========================================================================================
* Private
* ========================================================================================== */
/**
* Return true in case of success.
*/
private fun recursiveActionOnFile(file: File, action: ActionOnFile): Boolean {
if (file.isDirectory) {
file.list()?.forEach {
val result = recursiveActionOnFile(File(file, it), action)
if (!result) {
// Break the loop
return false
}
}
}
return action.invoke(file)
}
/**
* Get the file extension of a fileUri or a filename.
*
* @param fileUri the fileUri (can be a simple filename)
* @return the file extension, in lower case, or null is extension is not available or empty
*/
fun getFileExtension(fileUri: String): String? {
var reducedStr = fileUri
if (reducedStr.isNotEmpty()) {
// Remove fragment
reducedStr = reducedStr.substringBeforeLast('#')
// Remove query
reducedStr = reducedStr.substringBeforeLast('?')
// Remove path
val filename = reducedStr.substringAfterLast('/')
// Contrary to method MimeTypeMap.getFileExtensionFromUrl, we do not check the pattern
// See https://stackoverflow.com/questions/14320527/android-should-i-use-mimetypemap-getfileextensionfromurl-bugs
if (filename.isNotEmpty()) {
val dotPos = filename.lastIndexOf('.')
if (0 <= dotPos) {
val ext = filename.substring(dotPos + 1)
if (ext.isNotBlank()) {
return ext.lowercase(Locale.ROOT)
}
}
}
}
return null
}
/* ==========================================================================================
* Size
* ========================================================================================== */
@WorkerThread
fun File.getSizeOfFiles(): Long {
return walkTopDown()
.onEnter {
Timber.v("Get size of ${it.absolutePath}")
true
}
.sumOf { it.length() }
}

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.androidutils.filesize
import android.content.Context
import android.os.Build
import android.text.format.Formatter
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class AndroidFileSizeFormatter @Inject constructor(
@ApplicationContext private val context: Context,
) : FileSizeFormatter {
override fun format(fileSize: Long, useShortFormat: Boolean): String {
// Since Android O, the system considers that 1kB = 1000 bytes instead of 1024 bytes.
// We want to avoid that.
val normalizedSize = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) {
fileSize
} else {
// First convert the size
when {
fileSize < 1024 -> fileSize
fileSize < 1024 * 1024 -> fileSize * 1000 / 1024
fileSize < 1024 * 1024 * 1024 -> fileSize * 1000 / 1024 * 1000 / 1024
else -> fileSize * 1000 / 1024 * 1000 / 1024 * 1000 / 1024
}
}
return if (useShortFormat) {
Formatter.formatShortFileSize(context, normalizedSize)
} else {
Formatter.formatFileSize(context, normalizedSize)
}
}
}

View file

@ -14,12 +14,10 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline
import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter
package io.element.android.libraries.androidutils.filesize
class FakeFileSizeFormatter : FileSizeFormatter {
override fun format(fileSize: Long): String {
override fun format(fileSize: Long, useShortFormat: Boolean): String {
return "$fileSize Bytes"
}
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.androidutils.filesize
interface FileSizeFormatter {
/**
* Formats a content size to be in the form of bytes, kilobytes, megabytes, etc.
*/
fun format(fileSize: Long, useShortFormat: Boolean = true): String
}

View file

@ -17,6 +17,7 @@
package io.element.android.libraries.designsystem.components.preferences
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -26,7 +27,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.progressSemantics
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport
@ -55,7 +55,7 @@ fun PreferenceText(
tintColor: Color? = null,
onClick: () -> Unit = {},
) {
val minHeight = if (subtitle == null) preferenceMinHeightOnlyTitle else preferenceMinHeight
val minHeight = if (subtitle == null) preferenceMinHeightOnlyTitle else preferenceMinHeight
Box(
modifier = modifier
.fillMaxWidth()
@ -69,9 +69,10 @@ fun PreferenceText(
.padding(vertical = preferencePaddingVertical)
) {
PreferenceIcon(icon = icon, tintColor = tintColor)
Column(modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
Column(
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
) {
if (title != null) {
Text(
@ -92,15 +93,24 @@ fun PreferenceText(
}
}
if (currentValue != null) {
Text(currentValue, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
Spacer(Modifier.width(16.dp))
Text(
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(horizontal = 16.dp),
text = currentValue,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
)
} else if (loadingCurrentValue) {
CircularProgressIndicator(modifier = Modifier
.progressSemantics()
.size(20.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(16.dp))
CircularProgressIndicator(
modifier = Modifier
.progressSemantics()
.padding(horizontal = 16.dp)
.size(20.dp)
.align(Alignment.CenterVertically),
strokeWidth = 2.dp
)
}
}
}
}
@ -111,9 +121,39 @@ internal fun PreferenceTextPreview() = ElementThemedPreview { ContentToPreview()
@Composable
private fun ContentToPreview() {
PreferenceText(
title = "Title",
subtitle = "Some content",
icon = Icons.Default.BugReport,
)
Column(
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
PreferenceText(
title = "Title",
icon = Icons.Default.BugReport,
)
PreferenceText(
title = "Title",
subtitle = "Some content",
icon = Icons.Default.BugReport,
)
PreferenceText(
title = "Title",
subtitle = "Some content",
icon = Icons.Default.BugReport,
currentValue = "123",
)
PreferenceText(
title = "Title",
subtitle = "Some content",
icon = Icons.Default.BugReport,
loadingCurrentValue = true,
)
PreferenceText(
title = "Title",
icon = Icons.Default.BugReport,
currentValue = "123",
)
PreferenceText(
title = "Title",
icon = Icons.Default.BugReport,
loadingCurrentValue = true,
)
}
}

View file

@ -21,6 +21,7 @@ import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
@ -28,27 +29,31 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* A global dispatcher of [SnackbarMessage] to be displayed in [Snackbar] via a [SnackbarHostState].
*/
class SnackbarDispatcher {
private val mutex = Mutex()
private val snackbarState = MutableStateFlow<SnackbarMessage?>(null)
val snackbarMessage: Flow<SnackbarMessage?> = snackbarState
private val _snackbarMessage = MutableStateFlow<SnackbarMessage?>(null)
val snackbarMessage: Flow<SnackbarMessage?> = _snackbarMessage.asStateFlow()
suspend fun post(message: SnackbarMessage) {
mutex.withLock {
snackbarState.update { message }
_snackbarMessage.update { message }
}
}
suspend fun clear() {
mutex.withLock {
snackbarState.update { null }
_snackbarMessage.update { null }
}
}
}
@ -59,10 +64,8 @@ val LocalSnackbarDispatcher = compositionLocalOf<SnackbarDispatcher> {
}
@Composable
fun handleSnackbarMessage(
snackbarDispatcher: SnackbarDispatcher
): SnackbarMessage? {
return snackbarDispatcher.snackbarMessage.collectAsState(initial = null).value
fun SnackbarDispatcher.collectSnackbarMessageAsState(): State<SnackbarMessage?> {
return snackbarMessage.collectAsState(initial = null)
}
@Composable

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="state_event_avatar_changed_too">"(obrázok bol tiež zmenený)"</string>
<string name="state_event_avatar_url_changed">"%1$s zmenili svoj obrázok"</string>
<string name="state_event_avatar_url_changed_by_you">"Zmenili ste svoj obrázok"</string>
<string name="state_event_display_name_changed_from">"%1$s zmenili svoje zobrazované meno z %2$s na %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"Zmenili ste si zobrazované meno z %1$s na %2$s"</string>
<string name="state_event_display_name_removed">"%1$s odstránili svoje zobrazované meno (predtým bolo %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Odstránili ste svoje zobrazované meno (predtým bolo %1$s)"</string>
<string name="state_event_display_name_set">"%1$s nastavili svoje zobrazované meno na %2$s"</string>
<string name="state_event_display_name_set_by_you">"Svoje zobrazované meno ste nastavili na %1$s"</string>
<string name="state_event_room_avatar_changed">"%1$s zmenil/a obrázok miestnosti"</string>
<string name="state_event_room_avatar_changed_by_you">"Zmenili ste obrázok miestnosti"</string>
<string name="state_event_room_avatar_removed">"%1$s odstránil/a obrázok miestnosti"</string>
<string name="state_event_room_avatar_removed_by_you">"Odstránili ste obrázok miestnosti"</string>
<string name="state_event_room_ban">"%1$s zakázal/a používateľa %2$s"</string>
<string name="state_event_room_ban_by_you">"Zakázali ste používateľa %1$s"</string>
<string name="state_event_room_created">"%1$s vytvoril/a miestnosť"</string>
<string name="state_event_room_created_by_you">"Vytvorili ste miestnosť"</string>
<string name="state_event_room_invite">"%1$s pozval/a používateľa %2$s"</string>
<string name="state_event_room_invite_accepted">"%1$s prijal/a pozvanie"</string>
<string name="state_event_room_invite_accepted_by_you">"Prijali ste pozvánku"</string>
<string name="state_event_room_invite_by_you">"Pozvali ste používateľa %1$s"</string>
<string name="state_event_room_invite_you">"%1$s vás pozval/a"</string>
<string name="state_event_room_join">"%1$s sa pripojil/a do miestnosti"</string>
<string name="state_event_room_join_by_you">"Vstúpili ste do miestnosti"</string>
<string name="state_event_room_knock">"%1$s požiadal o pripojenie"</string>
<string name="state_event_room_knock_accepted">"%1$s umožnil/a používateľovi %2$s pripojiť sa"</string>
<string name="state_event_room_knock_accepted_by_you">"%1$s vám umožnil/a pripojiť sa"</string>
<string name="state_event_room_knock_by_you">"Požiadali ste o pripojenie"</string>
<string name="state_event_room_knock_retracted_by_you">"Zrušili ste svoju žiadosť o pripojenie"</string>
<string name="state_event_room_leave">"%1$s opustil/a miestnosť"</string>
<string name="state_event_room_leave_by_you">"Opustili ste miestnosť"</string>
<string name="state_event_room_name_changed">"%1$s zmenil/a názov miestnosti na: %2$s"</string>
<string name="state_event_room_name_changed_by_you">"Zmenili ste názov miestnosti na: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s odstránil/a názov miestnosti"</string>
<string name="state_event_room_name_removed_by_you">"Odstránili ste názov miestnosti"</string>
<string name="state_event_room_reject">"%1$s odmietol/a pozvánku"</string>
<string name="state_event_room_reject_by_you">"Odmietli ste pozvánku"</string>
<string name="state_event_room_remove">"%1$s odstránil/a %2$s"</string>
<string name="state_event_room_remove_by_you">"Odstránili ste %1$s"</string>
<string name="state_event_room_third_party_invite">"%1$s poslal/a pozvánku používateľovi %2$s, aby sa pripojil k miestnosti"</string>
<string name="state_event_room_third_party_invite_by_you">"Poslali ste pozvánku používateľovi %1$s, aby sa pripojil do miestnosti"</string>
<string name="state_event_room_topic_changed">"%1$s zmenil/a tému na: %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"Zmenili ste tému na: %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s odstránil/a tému miestnosti"</string>
<string name="state_event_room_topic_removed_by_you">"Odstránili ste tému miestnosti"</string>
<string name="state_event_room_unban">"%1$s zrušil/a zákaz pre %2$s"</string>
<string name="state_event_room_unban_by_you">"Zrušili ste zákaz pre %1$s"</string>
</resources>

View file

@ -49,6 +49,12 @@ interface MatrixClient : Closeable {
fun sessionVerificationService(): SessionVerificationService
fun pushersService(): PushersService
fun notificationService(): NotificationService
suspend fun getCacheSize(): Long
/**
* Will close the client and delete the cache data.
*/
suspend fun clearCache()
suspend fun logout()
suspend fun loadUserDisplayName(): Result<String>
suspend fun loadUserAvatarURLString(): Result<String?>

View file

@ -21,5 +21,5 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
interface NotificationService {
fun getNotification(userId: SessionId, roomId: RoomId, eventId: EventId): Result<NotificationData?>
fun getNotification(userId: SessionId, roomId: RoomId, eventId: EventId, filterByPushRules: Boolean): Result<NotificationData?>
}

View file

@ -116,4 +116,13 @@ interface MatrixRoom : Closeable {
suspend fun setTopic(topic: String): Result<Unit>
suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result<Unit>
/**
* Share a location message in the room.
*
* @param body A human readable textual representation of the location.
* @param geoUri A geo URI (RFC 5870) representing the location e.g. `geo:51.5008,0.1247;u=35`.
* Respectively: latitude, longitude, and (optional) uncertainty.
*/
suspend fun sendLocation(body: String, geoUri: String): Result<Unit>
}

View file

@ -32,6 +32,7 @@ dependencies {
// api(projects.libraries.rustsdk)
implementation(libs.matrix.sdk)
implementation(projects.libraries.di)
implementation(projects.libraries.androidutils)
implementation(projects.services.toolbox.api)
api(projects.libraries.matrix.api)
implementation(libs.dagger)

View file

@ -16,6 +16,8 @@
package io.element.android.libraries.matrix.impl
import io.element.android.libraries.androidutils.file.getSizeOfFiles
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScopeOf
import io.element.android.libraries.matrix.api.MatrixClient
@ -229,15 +231,25 @@ class RustMatrixClient constructor(
client.destroy()
}
override suspend fun getCacheSize(): Long {
// Do not use client.userId since it can throw if client has been closed (during clear cache)
return baseDirectory.getCacheSize(userID = sessionId.value)
}
override suspend fun clearCache() {
close()
baseDirectory.deleteSessionDirectory(userID = sessionId.value, deleteCryptoDb = false)
}
override suspend fun logout() = withContext(dispatchers.io) {
try {
client.logout()
} catch (failure: Throwable) {
Timber.e(failure, "Fail to call logout on HS. Still delete local files.")
}
baseDirectory.deleteSessionDirectory(userID = client.userId())
sessionStore.removeSession(client.userId())
close()
baseDirectory.deleteSessionDirectory(userID = sessionId.value, deleteCryptoDb = true)
sessionStore.removeSession(sessionId.value)
}
override suspend fun loadUserDisplayName(): Result<String> = withContext(dispatchers.io) {
@ -271,11 +283,48 @@ class RustMatrixClient constructor(
override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver
private fun File.deleteSessionDirectory(userID: String): Boolean {
private suspend fun File.getCacheSize(
userID: String,
includeCryptoDb: Boolean = false,
): Long = withContext(dispatchers.io) {
// Rust sanitises the user ID replacing invalid characters with an _
val sanitisedUserID = userID.replace(":", "_")
val sessionDirectory = File(this, sanitisedUserID)
return sessionDirectory.deleteRecursively()
val sessionDirectory = File(this@getCacheSize, sanitisedUserID)
if (includeCryptoDb) {
sessionDirectory.getSizeOfFiles()
} else {
listOf(
"matrix-sdk-state.sqlite3",
"matrix-sdk-state.sqlite3-shm",
"matrix-sdk-state.sqlite3-wal",
).map { fileName ->
File(sessionDirectory, fileName)
}.sumOf { file ->
file.length()
}
}
}
private suspend fun File.deleteSessionDirectory(
userID: String,
deleteCryptoDb: Boolean = false,
): Boolean = withContext(dispatchers.io) {
// Rust sanitises the user ID replacing invalid characters with an _
val sanitisedUserID = userID.replace(":", "_")
val sessionDirectory = File(this@deleteSessionDirectory, sanitisedUserID)
if (deleteCryptoDb) {
// Delete the folder and all its content
sessionDirectory.deleteRecursively()
} else {
// Delete only the state.db file
sessionDirectory.listFiles().orEmpty()
.filter { it.name.contains("matrix-sdk-state") }
.forEach { file ->
Timber.w("Deleting file ${file.name}...")
file.safeDelete()
}
true
}
}
}

View file

@ -32,10 +32,11 @@ class RustNotificationService(
override fun getNotification(
userId: SessionId,
roomId: RoomId,
eventId: EventId
eventId: EventId,
filterByPushRules: Boolean,
): Result<NotificationData?> {
return runCatching {
client.getNotificationItem(roomId.value, eventId.value)?.use(notificationMapper::map)
client.getNotificationItem(roomId.value, eventId.value, filterByPushRules)?.use(notificationMapper::map)
}
}
}

View file

@ -374,4 +374,13 @@ class RustMatrixRoom(
}
}
}
override suspend fun sendLocation(
body: String,
geoUri: String
): Result<Unit> = withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.sendLocation(body, geoUri, genTransactionId())
}
}
}

View file

@ -101,6 +101,13 @@ class FakeMatrixClient(
override fun syncService() = syncService
override suspend fun getCacheSize(): Long {
return 0
}
override suspend fun clearCache() {
}
override suspend fun logout() {
delay(100)
logoutFailure?.let { throw it }

View file

@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.flowOf
val A_OIDC_DATA = OidcDetails(url = "a-url")
class FakeAuthenticationService : MatrixAuthenticationService {
private var homeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
private val homeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
private var oidcError: Throwable? = null
private var oidcCancelError: Throwable? = null
private var loginError: Throwable? = null

View file

@ -23,7 +23,7 @@ import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.notification.NotificationService
class FakeNotificationService : NotificationService {
override fun getNotification(userId: SessionId, roomId: RoomId, eventId: EventId): Result<NotificationData?> {
override fun getNotification(userId: SessionId, roomId: RoomId, eventId: EventId, filterByPushRules: Boolean): Result<NotificationData?> {
return Result.success(null)
}
}

View file

@ -77,6 +77,7 @@ class FakeMatrixRoom(
private var cancelSendResult = Result.success(Unit)
private var forwardEventResult = Result.success(Unit)
private var reportContentResult = Result.success(Unit)
private var sendLocationResult = Result.success(Unit)
var sendMediaCount = 0
private set
@ -93,6 +94,9 @@ class FakeMatrixRoom(
var reportedContentCount: Int = 0
private set
var sendLocationCount: Int = 0
private set
var isInviteAccepted: Boolean = false
private set
@ -262,6 +266,14 @@ class FakeMatrixRoom(
return reportContentResult
}
override suspend fun sendLocation(
body: String,
geoUri: String
): Result<Unit> = simulateLongTask {
sendLocationCount++
return sendLocationResult
}
override fun close() = Unit
fun givenLeaveRoomError(throwable: Throwable?) {
@ -355,4 +367,8 @@ class FakeMatrixRoom(
fun givenReportContentResult(result: Result<Unit>) {
reportContentResult = result
}
fun givenSendLocationResult(result: Result<Unit>) {
sendLocationResult = result
}
}

View file

@ -26,16 +26,17 @@ import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClient
import okhttp3.OkHttpClient
import javax.inject.Inject
import javax.inject.Provider
class LoggedInImageLoaderFactory @Inject constructor(
@ApplicationContext private val context: Context,
private val matrixClient: MatrixClient,
private val okHttpClient: OkHttpClient,
private val okHttpClient: Provider<OkHttpClient>,
) : ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
return ImageLoader
.Builder(context)
.okHttpClient(okHttpClient)
.okHttpClient { okHttpClient.get() }
.components {
// Add gif support
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
@ -54,12 +55,12 @@ class LoggedInImageLoaderFactory @Inject constructor(
class NotLoggedInImageLoaderFactory @Inject constructor(
@ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient,
private val okHttpClient: Provider<OkHttpClient>,
) : ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
return ImageLoader
.Builder(context)
.okHttpClient(okHttpClient)
.okHttpClient { okHttpClient.get() }
.build()
}
}

View file

@ -59,6 +59,7 @@ class NotifiableEventResolver @Inject constructor(
userId = sessionId,
roomId = roomId,
eventId = eventId,
filterByPushRules = true,
)
}.fold(
{

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Zavolať"</string>
<string name="notification_channel_silent">"Tiché oznámenia"</string>
<string name="notification_inline_reply_failed">"** Nepodarilo sa odoslať - prosím otvorte miestnosť"</string>
<string name="notification_invitation_action_join">"Pripojiť sa"</string>
<string name="notification_invitation_action_reject">"Zamietnuť"</string>
<string name="notification_new_messages">"Nové správy"</string>
<string name="notification_room_action_mark_as_read">"Označiť ako prečítané"</string>
<string name="notification_test_push_notification_content">"Prezeráte si oznámenie! Kliknite na mňa!"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<string name="notification_unread_notified_messages_and_invitation">"%1$s a %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s v %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s v %2$s a %3$s"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d správa"</item>
<item quantity="few">"%1$s: %2$d správy"</item>
<item quantity="other">"%1$s: %2$d správ"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="one">"%d oznámenie"</item>
<item quantity="few">"%d oznámenia"</item>
<item quantity="other">"%d oznámení"</item>
</plurals>
<plurals name="notification_invitations">
<item quantity="one">"%d pozvánka"</item>
<item quantity="few">"%d pozvánky"</item>
<item quantity="other">"%d pozvánok"</item>
</plurals>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"%d nová správa"</item>
<item quantity="few">"%d nové správy"</item>
<item quantity="other">"%d nových správ"</item>
</plurals>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"%d neprečítaná oznámená správa"</item>
<item quantity="few">"%d neprečítané oznámené správy"</item>
<item quantity="other">"%d neprečítaných oznámených správ"</item>
</plurals>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"%d miestnosť"</item>
<item quantity="few">"%d miestnosti"</item>
<item quantity="other">"%d miestností"</item>
</plurals>
<string name="push_choose_distributor_dialog_title_android">"Vyberte spôsob prijímania oznámení"</string>
<string name="push_distributor_background_sync_android">"Synchronizácia na pozadí"</string>
<string name="push_distributor_firebase_android">"Služby Google"</string>
<string name="notification_room_action_quick_reply">"Rýchla odpoveď"</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_composer_placeholder">"Správa…"</string>
<string name="rich_text_editor_format_bold">"Použiť tučný formát"</string>
<string name="rich_text_editor_format_italic">"Použiť formát kurzívy"</string>
<string name="rich_text_editor_format_strikethrough">"Použiť formát prečiarknutia"</string>
<string name="rich_text_editor_format_underline">"Použiť formát podčiarknutia"</string>
<string name="rich_text_editor_link">"Nastaviť odkaz"</string>
</resources>

View file

@ -28,6 +28,7 @@
<string name="action_invite">"Pozvat"</string>
<string name="action_invite_friends">"Pozvat přátele"</string>
<string name="action_invite_friends_to_app">"Pozvat přátele do %1$s"</string>
<string name="action_invite_people_to_app">"Pozvat lidi na %1$s"</string>
<string name="action_invites_list">"Pozvánky"</string>
<string name="action_learn_more">"Zjistit více"</string>
<string name="action_leave">"Odejít"</string>
@ -108,6 +109,7 @@
<string name="common_server_not_supported">"Server není podporován"</string>
<string name="common_server_url">"URL serveru"</string>
<string name="common_settings">"Nastavení"</string>
<string name="common_shared_location">"Sdílená poloha"</string>
<string name="common_starting_chat">"Zahajování chatu…"</string>
<string name="common_sticker">"Nálepka"</string>
<string name="common_success">"Úspěch"</string>
@ -163,6 +165,8 @@
<string name="screen_media_upload_preview_error_failed_processing">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_report_content_block_user_hint">"Zaškrtněte, pokud chcete skrýt všechny aktuální a budoucí zprávy od tohoto uživatele"</string>
<string name="screen_share_location_title">"Sdílet polohu"</string>
<string name="screen_share_my_location_action">"Sdílet moji polohu"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Práh detekce"</string>
<string name="settings_title_general">"Obecné"</string>

View file

@ -158,7 +158,7 @@
<string name="screen_analytics_settings_share_data">"Teile Analyse-Daten"</string>
<string name="screen_media_picker_error_failed_selection">"Medienauswahl fehlgeschlagen, bitte versuche es erneut."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Fehler bei der Verarbeitung von Medien zum Hochladen, bitte versuchen Sie es erneut."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Medien hochladen fehlgeschlagen. Bitte versuchen Sie es erneut."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Hochladen von Medien fehlgeschlagen, bitte versuchen Sie es erneut."</string>
<string name="screen_report_content_block_user_hint">"Prüfe, ob du alle aktuellen und zukünftigen Nachrichten dieses Benutzers ausblenden möchtest"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Erkennungsschwelle"</string>

View file

@ -0,0 +1,158 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="a11y_hide_password">"Skryť heslo"</string>
<string name="a11y_send_files">"Odoslať súbory"</string>
<string name="a11y_show_password">"Zobraziť heslo"</string>
<string name="a11y_user_menu">"Používateľské menu"</string>
<string name="action_accept">"Prijať"</string>
<string name="action_back">"Späť"</string>
<string name="action_cancel">"Zrušiť"</string>
<string name="action_choose_photo">"Vybrať fotku"</string>
<string name="action_clear">"Vyčistiť"</string>
<string name="action_close">"Zavrieť"</string>
<string name="action_complete_verification">"Dokončiť overenie"</string>
<string name="action_confirm">"Potvrdiť"</string>
<string name="action_continue">"Pokračovať"</string>
<string name="action_copy">"Kopírovať"</string>
<string name="action_copy_link">"Kopírovať odkaz"</string>
<string name="action_copy_link_to_message">"Kopírovať odkaz do správy"</string>
<string name="action_create">"Vytvoriť"</string>
<string name="action_create_a_room">"Vytvoriť miestnosť"</string>
<string name="action_decline">"Odmietnuť"</string>
<string name="action_disable">"Vypnúť"</string>
<string name="action_done">"Hotovo"</string>
<string name="action_edit">"Upraviť"</string>
<string name="action_enable">"Povoliť"</string>
<string name="action_forgot_password">"Zabudnuté heslo?"</string>
<string name="action_invite">"Pozvať"</string>
<string name="action_invite_friends">"Pozvať priateľov"</string>
<string name="action_invite_friends_to_app">"Pozvať priateľov do %1$s"</string>
<string name="action_invite_people_to_app">"Pozvať ľudí do %1$s"</string>
<string name="action_invites_list">"Pozvánky"</string>
<string name="action_learn_more">"Zistiť viac"</string>
<string name="action_leave">"Opustiť"</string>
<string name="action_leave_room">"Opustiť miestnosť"</string>
<string name="action_next">"Ďalej"</string>
<string name="action_no">"Nie"</string>
<string name="action_not_now">"Teraz nie"</string>
<string name="action_ok">"OK"</string>
<string name="action_open_with">"Otvoriť pomocou"</string>
<string name="action_quick_reply">"Rýchla odpoveď"</string>
<string name="action_quote">"Citovať"</string>
<string name="action_remove">"Odstrániť"</string>
<string name="action_reply">"Odpovedať"</string>
<string name="action_report_bug">"Nahlásiť chybu"</string>
<string name="action_report_content">"Nahlásiť obsah"</string>
<string name="action_retry">"Skúsiť znova"</string>
<string name="action_retry_decryption">"Opakovať dešifrovanie"</string>
<string name="action_save">"Uložiť"</string>
<string name="action_search">"Hľadať"</string>
<string name="action_send">"Odoslať"</string>
<string name="action_send_message">"Odoslať správu"</string>
<string name="action_share">"Zdieľať"</string>
<string name="action_share_link">"Zdieľať odkaz"</string>
<string name="action_skip">"Preskočiť"</string>
<string name="action_start">"Spustiť"</string>
<string name="action_start_chat">"Začať konverzáciu"</string>
<string name="action_start_verification">"Spustiť overovanie"</string>
<string name="action_static_map_load">"Ťuknutím načítate mapu"</string>
<string name="action_take_photo">"Urobiť fotku"</string>
<string name="action_view_source">"Zobraziť zdroj"</string>
<string name="action_yes">"Áno"</string>
<string name="common_about">"O aplikácii"</string>
<string name="common_analytics">"Analytika"</string>
<string name="common_audio">"Zvuk"</string>
<string name="common_bubbles">"Bubliny"</string>
<string name="common_copyright">"Autorské práva"</string>
<string name="common_creating_room">"Vytváranie miestnosti…"</string>
<string name="common_current_user_left_room">"Opustil/a miestnosť"</string>
<string name="common_decryption_error">"Chyba dešifrovania"</string>
<string name="common_developer_options">"Možnosti pre vývojárov"</string>
<string name="common_edited_suffix">"(upravené)"</string>
<string name="common_editing">"Upravuje sa"</string>
<string name="common_emote">"* %1$s %2$s"</string>
<string name="common_encryption_enabled">"Šifrovanie zapnuté"</string>
<string name="common_error">"Chyba"</string>
<string name="common_file">"Súbor"</string>
<string name="common_file_saved_on_disk_android">"Súbor bol uložený do priečinka Stiahnuté súbory"</string>
<string name="common_forward_message">"Preposlať správu"</string>
<string name="common_gif">"GIF"</string>
<string name="common_image">"Obrázok"</string>
<string name="common_invite_unknown_profile">"Nedokážeme overiť Matrix ID tohto používateľa. Pozvánka nemusí byť prijatá."</string>
<string name="common_link_copied_to_clipboard">"Odkaz bol skopírovaný do schránky"</string>
<string name="common_loading">"Načítava sa…"</string>
<string name="common_message">"Správa"</string>
<string name="common_message_layout">"Rozloženie správy"</string>
<string name="common_message_removed">"Správa odstránená"</string>
<string name="common_modern">"Moderné"</string>
<string name="common_no_results">"Žiadne výsledky"</string>
<string name="common_offline">"Offline"</string>
<string name="common_password">"Heslo"</string>
<string name="common_people">"Ľudia"</string>
<string name="common_permalink">"Trvalý odkaz"</string>
<string name="common_reactions">"Reakcie"</string>
<string name="common_report_a_bug">"Nahlásiť chybu"</string>
<string name="common_report_submitted">"Nahlásenie bolo odoslané"</string>
<string name="common_room_name">"Názov miestnosti"</string>
<string name="common_search_results">"Výsledky hľadania"</string>
<string name="common_security">"Bezpečnosť"</string>
<string name="common_select_your_server">"Vyberte svoj server"</string>
<string name="common_sending">"Odosiela sa…"</string>
<string name="common_server_not_supported">"Server nie je podporovaný"</string>
<string name="common_server_url">"URL adresa servera"</string>
<string name="common_settings">"Nastavenia"</string>
<string name="common_shared_location">"Zdieľaná poloha"</string>
<string name="common_sticker">"Nálepka"</string>
<string name="common_success">"Úspech"</string>
<string name="common_suggestions">"Návrhy"</string>
<string name="common_syncing">"Synchronizuje sa"</string>
<string name="common_topic">"Téma"</string>
<string name="common_topic_placeholder">"O čom je táto miestnosť?"</string>
<string name="common_unable_to_decrypt">"Nie je možné dešifrovať"</string>
<string name="common_unsupported_event">"Nepodporovaná udalosť"</string>
<string name="common_username">"Používateľské meno"</string>
<string name="common_verification_cancelled">"Overovanie zrušené"</string>
<string name="common_verification_complete">"Overovanie je dokončené"</string>
<string name="common_video">"Video"</string>
<string name="common_waiting">"Čaká sa…"</string>
<string name="dialog_title_confirmation">"Potvrdenie"</string>
<string name="dialog_title_warning">"Upozornenie"</string>
<string name="emoji_picker_category_activity">"Aktivity"</string>
<string name="emoji_picker_category_flags">"Vlajky"</string>
<string name="emoji_picker_category_foods">"Jedlo a nápoje"</string>
<string name="emoji_picker_category_nature">"Zvieratá a príroda"</string>
<string name="emoji_picker_category_objects">"Predmety"</string>
<string name="emoji_picker_category_people">"Smajlíky a ľudia"</string>
<string name="emoji_picker_category_places">"Cestovanie a miesta"</string>
<string name="emoji_picker_category_symbols">"Symboly"</string>
<string name="error_failed_loading_messages">"Načítanie správ zlyhalo"</string>
<string name="error_some_messages_have_not_been_sent">"Niektoré správy neboli odoslané"</string>
<string name="error_unknown">"Prepáčte, vyskytla sa chyba"</string>
<string name="invite_friends_rich_title">"🔐️ Pripojte sa ku mne na %1$s"</string>
<string name="invite_friends_text">"Ahoj, porozprávajte sa so mnou na %1$s: %2$s"</string>
<string name="leave_room_alert_empty_subtitle">"Ste si istí, že chcete opustiť túto miestnosť? Ste tu jediná osoba. Ak odídete, nikto sa do nej nebude môcť v budúcnosti pripojiť, vrátane vás."</string>
<string name="leave_room_alert_private_subtitle">"Ste si istí, že chcete opustiť túto miestnosť? Táto miestnosť nie je verejná a bez pozvania sa do nej nebudete môcť vrátiť."</string>
<string name="leave_room_alert_subtitle">"Ste si istí, že chcete opustiť miestnosť?"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<plurals name="common_member_count">
<item quantity="one">"%1$d člen"</item>
<item quantity="few">"%1$d členovia"</item>
<item quantity="other">"%1$d členov"</item>
</plurals>
<string name="preference_rageshake">"Zúrivo potriasť pre nahlásenie chyby"</string>
<string name="rageshake_dialog_content">"Zdá sa, že zúrivo trasiete telefónom. Chcete otvoriť obrazovku s nahlásením chýb?"</string>
<string name="room_timeline_beginning_of_room">"Toto je začiatok %1$s."</string>
<string name="room_timeline_beginning_of_room_no_name">"Toto je začiatok tejto konverzácie."</string>
<string name="room_timeline_read_marker_title">"Nové"</string>
<string name="screen_share_location_title">"Zdieľať polohu"</string>
<string name="screen_share_my_location_action">"Zdieľať moju polohu"</string>
<string name="screen_share_this_location_action">"Zdieľajte túto polohu"</string>
<string name="settings_rageshake">"Zúrivé potrasenie"</string>
<string name="settings_rageshake_detection_threshold">"Prahová hodnota detekcie"</string>
<string name="settings_title_general">"Všeobecné"</string>
<string name="settings_version_number">"Verzia: %1$s (%2$s)"</string>
<string name="test_language_identifier">"sk"</string>
<string name="dialog_title_error">"Chyba"</string>
<string name="dialog_title_success">"Úspech"</string>
<string name="screen_report_content_block_user">"Zablokovať používateľa"</string>
</resources>

View file

@ -164,6 +164,9 @@
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>
<string name="screen_report_content_block_user_hint">"Check if you want to hide all current and future messages from this user"</string>
<string name="screen_share_location_title">"Share location"</string>
<string name="screen_share_my_location_action">"Share my location"</string>
<string name="screen_share_this_location_action">"Share this location"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Detection threshold"</string>
<string name="settings_title_general">"General"</string>

View file

@ -43,6 +43,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import timber.log.Timber
import java.util.Locale
class RoomListScreen(
@ -106,8 +107,10 @@ class RoomListScreen(
)
DisposableEffect(Unit) {
Timber.w("Start sync!")
matrixClient.startSync()
onDispose {
Timber.w("Stop sync!")
matrixClient.stopSync()
}
}

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:407a17fca1405575861b7c8861222a5d529778eeaeb904c3058fe19ff9f809fc
size 143328

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:95068257a39fc8a693adce87c54b605be395de5fd639ee00b7d34dde5bce568a
size 147055

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:819c585286d2ef5c7064766d537b9da62be54b67340a5a7a44e94dcf53b1caf4
size 277318

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c0e68e68e1cf4f735671b5abcfb4e0c936ca1a00ead817e8f010d39f5b753252
size 280801

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5d34ac54f8c46bc3752366adeefb0952b23f052f9359b48864a6a43455334a6d
size 4965

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b
size 4457

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:407a17fca1405575861b7c8861222a5d529778eeaeb904c3058fe19ff9f809fc
size 143328

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:819c585286d2ef5c7064766d537b9da62be54b67340a5a7a44e94dcf53b1caf4
size 277318

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5d806cbab0f26fb4f471adb5fbecfc600603a7651c5391501d42b13b23617a4c
size 29301
oid sha256:9597821bbe6b65693470b40e5f570cf318821d2dbf5bdf525d447daff7d352ae
size 35345

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9597821bbe6b65693470b40e5f570cf318821d2dbf5bdf525d447daff7d352ae
size 35345

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d04eb5d2ebb8b740edcaac00912994e8c164e7b5083595d4085d350af0ca6c33
size 28482
oid sha256:61880d7b08cc92743a12a74246495039b76e1e80e2704839f726329efb6958a0
size 34308

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:61880d7b08cc92743a12a74246495039b76e1e80e2704839f726329efb6958a0
size 34308

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:04e83ead8c23ec9160eb9cfd656aa5e9bf8c302dcf435d00b78aa2eba3243f0d
size 64181
oid sha256:05dc60cbfa8de0e27acecc5d44f7e1841d15f1cdcb749dcf23995aa49d40d924
size 64174

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b59df47ab2ad44752eb3441ed117fff517e42c2c08f5e0c6402168223f35daef
size 53042
oid sha256:dc5b5b7a08b61201bde775880eadd40dc51773fe616209c85c143fc703091cef
size 53024

Some files were not shown because too many files have changed in this diff Show more