Merge branch 'develop' into feature/fga/power_level

This commit is contained in:
ganfra 2023-07-18 22:54:33 +02:00
commit 1d2fd52ce6
188 changed files with 813 additions and 628 deletions

View file

@ -34,10 +34,12 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.6.0
uses: gradle/gradle-build-action@v2.6.1
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug APKs
uses: actions/upload-artifact@v3

View file

@ -35,6 +35,7 @@ jobs:
run: |
./gradlew assembleNightly appDistributionUploadNightly $CI_GRADLE_ARG_PROPERTIES
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}

View file

@ -62,7 +62,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.6.0
uses: gradle/gradle-build-action@v2.6.1
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis

View file

@ -39,7 +39,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.6.0
uses: gradle/gradle-build-action@v2.6.1
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run code quality check suite

View file

@ -24,7 +24,7 @@ jobs:
java-version: '17'
# Add gradle cache, this should speed up the process
- name: Configure gradle
uses: gradle/gradle-build-action@v2.6.0
uses: gradle/gradle-build-action@v2.6.1
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots

View file

@ -58,6 +58,9 @@ if [[ -z ${REPO} ]]; then
exit 1
fi
echo "Deleting previous screenshots"
./gradlew removeOldSnapshots --stacktrace -PpreDexEnable=false --max-workers 4 --warn
echo "Record screenshots"
./gradlew recordPaparazziDebug --stacktrace -PpreDexEnable=false --max-workers 4 --warn

View file

@ -33,7 +33,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.6.0
uses: gradle/gradle-build-action@v2.6.1
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}

View file

@ -2,6 +2,7 @@
<dictionary name="shared">
<words>
<w>backstack</w>
<w>ftue</w>
<w>homeserver</w>
<w>kover</w>
<w>measurables</w>

View file

@ -16,7 +16,6 @@
package io.element.android.appnav.loggedin
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@ -32,14 +31,12 @@ fun LoggedInView(
state: LoggedInState,
modifier: Modifier = Modifier
) {
val activity = LocalContext.current as? Activity
val context = LocalContext.current
PermissionsView(
state = state.permissionsState,
modifier = modifier,
openSystemSettings = {
activity?.let { openAppSettingsPage(it) }
}
openSystemSettings = context::openAppSettingsPage
)
}

42
docs/maps.md Normal file
View file

@ -0,0 +1,42 @@
# Use of maps
<!--- TOC -->
* [Overview](#overview)
* [Local development with MapTiler](#local-development-with-maptiler)
* [Making releasable builds with MapTiler](#making-releasable-builds-with-maptiler)
* [Using other map sources or MapTiler styles](#using-other-map-sources-or-maptiler-styles)
<!--- END -->
## Overview
Element Android uses [MapTiler](https://www.maptiler.com/) to provide map
imagery where required. MapTiler requires an API key, which we bake in to
the app at release time.
## Local development with MapTiler
If you're developing the application and want maps to render properly you can
sign up for the [MapTiler free tier](https://www.maptiler.com/cloud/pricing/).
Place your API key in `local.properties` with the key
`services.maptiler.apikey`, e.g.:
```properties
services.maptiler.apikey=abCd3fGhijK1mN0pQr5t
```
## Making releasable builds with MapTiler
To insert the MapTiler API key when building an APK, set the
`ELEMENT_ANDROID_MAPTILER_API_KEY` environment variable in your build
environment.
## Using other map sources or MapTiler styles
If you wish to use an alternative map provider, or custom MapTiler styles,
you can customise the functions in
`features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt`.
We've kept this file small and self contained to minimise the chances of merge
collisions in forks.

View file

@ -135,7 +135,7 @@ private fun AnalyticsOptInHeader(
@Composable
private fun CheckIcon(modifier: Modifier = Modifier) {
Icon(
modifier = Modifier
modifier = modifier
.size(20.dp)
.background(color = MaterialTheme.colorScheme.background, shape = CircleShape)
.padding(2.dp),

View file

@ -67,4 +67,8 @@ class FakeAnalyticsService(
override fun trackError(throwable: Throwable) {
}
override suspend fun reset() {
didAskUserConsentFlow.value = false
}
}

View file

@ -20,4 +20,6 @@ import kotlinx.coroutines.flow.StateFlow
interface FtueState {
val shouldDisplayFlow: StateFlow<Boolean>
suspend fun reset()
}

View file

@ -39,6 +39,11 @@ class DefaultFtueState @Inject constructor(
override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete())
override suspend fun reset() {
welcomeScreenState.reset()
analyticsService.reset()
}
init {
analyticsService.didAskUserConsent()
.onEach { updateState() }

View file

@ -75,13 +75,15 @@ fun WelcomeView(
Text(
modifier = Modifier.testTag(TestTags.welcomeScreenTitle),
text = stringResource(R.string.screen_welcome_title, applicationName),
style = ElementTheme.typography.fontHeadingLgBold,
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.screen_welcome_subtitle),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(40.dp))

View file

@ -17,6 +17,7 @@
package io.element.android.features.ftue.impl.welcome.state
import android.content.SharedPreferences
import androidx.core.content.edit
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.DefaultPreferences
@ -27,7 +28,7 @@ import javax.inject.Inject
@SingleIn(AppScope::class)
class AndroidWelcomeScreenState @Inject constructor(
@DefaultPreferences private val sharedPreferences: SharedPreferences,
): WelcomeScreenState {
) : WelcomeScreenState {
companion object {
private const val IS_WELCOME_SCREEN_SHOWN = "is_welcome_screen_shown"
@ -40,4 +41,10 @@ class AndroidWelcomeScreenState @Inject constructor(
override fun setWelcomeScreenShown() {
sharedPreferences.edit().putBoolean(IS_WELCOME_SCREEN_SHOWN, true).apply()
}
override fun reset() {
sharedPreferences.edit {
remove(IS_WELCOME_SCREEN_SHOWN)
}
}
}

View file

@ -19,4 +19,5 @@ package io.element.android.features.ftue.impl.welcome.state
interface WelcomeScreenState {
fun isWelcomeScreenNeeded(): Boolean
fun setWelcomeScreenShown()
fun reset()
}

View file

@ -27,4 +27,8 @@ class FakeWelcomeState : WelcomeScreenState {
override fun setWelcomeScreenShown() {
isWelcomeScreenNeeded = false
}
override fun reset() {
isWelcomeScreenNeeded = true
}
}

View file

@ -139,7 +139,7 @@ class InviteListPresenter @Inject constructor(
private fun CoroutineScope.acceptInvite(roomId: RoomId, acceptedAction: MutableState<Async<RoomId>>) = launch {
suspend {
client.getRoom(roomId)?.use {
it.acceptInvitation().getOrThrow()
it.join().getOrThrow()
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
analyticsService.capture(it.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite))
}
@ -150,7 +150,7 @@ class InviteListPresenter @Inject constructor(
private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState<Async<Unit>>) = launch {
suspend {
client.getRoom(roomId)?.use {
it.rejectInvitation().getOrThrow()
it.leave().getOrThrow()
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
}
Unit

View file

@ -211,7 +211,6 @@ class InviteListPresenterTests {
skipItems(2)
Truth.assertThat(room.isInviteRejected).isTrue()
Truth.assertThat(fakeNotificationDrawerManager.getClearMembershipNotificationForRoomCount(client.sessionId, A_ROOM_ID)).isEqualTo(1)
}
}
@ -225,7 +224,7 @@ class InviteListPresenterTests {
val room = FakeMatrixRoom()
val presenter = createPresenter(client)
val ex = Throwable("Ruh roh!")
room.givenRejectInviteResult(Result.failure(ex))
room.givenLeaveRoomError(ex)
client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) {
@ -242,7 +241,6 @@ class InviteListPresenterTests {
val newState = awaitItem()
Truth.assertThat(room.isInviteRejected).isTrue()
Truth.assertThat(newState.declinedAction).isEqualTo(Async.Failure<Unit>(ex))
}
}
@ -256,7 +254,7 @@ class InviteListPresenterTests {
val room = FakeMatrixRoom()
val presenter = createPresenter(client)
val ex = Throwable("Ruh roh!")
room.givenRejectInviteResult(Result.failure(ex))
room.givenLeaveRoomError(ex)
client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) {
@ -298,7 +296,6 @@ class InviteListPresenterTests {
val newState = awaitItem()
Truth.assertThat(room.isInviteAccepted).isTrue()
Truth.assertThat(newState.acceptedAction).isEqualTo(Async.Success(A_ROOM_ID))
Truth.assertThat(fakeNotificationDrawerManager.getClearMembershipNotificationForRoomCount(client.sessionId, A_ROOM_ID)).isEqualTo(1)
}
@ -313,7 +310,7 @@ class InviteListPresenterTests {
val room = FakeMatrixRoom()
val presenter = createPresenter(client)
val ex = Throwable("Ruh roh!")
room.givenAcceptInviteResult(Result.failure(ex))
room.givenJoinRoomResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) {
@ -322,10 +319,7 @@ class InviteListPresenterTests {
val originalState = awaitItem()
originalState.eventSink(InviteListEvents.AcceptInvite(originalState.inviteList[0]))
val newState = awaitItem()
Truth.assertThat(room.isInviteAccepted).isTrue()
Truth.assertThat(newState.acceptedAction).isEqualTo(Async.Failure<RoomId>(ex))
Truth.assertThat(awaitItem().acceptedAction).isEqualTo(Async.Failure<RoomId>(ex))
}
}
@ -338,7 +332,7 @@ class InviteListPresenterTests {
val room = FakeMatrixRoom()
val presenter = createPresenter(client)
val ex = Throwable("Ruh roh!")
room.givenAcceptInviteResult(Result.failure(ex))
room.givenJoinRoomResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) {

View file

@ -14,14 +14,33 @@
* limitations under the License.
*/
import java.util.Properties
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.ksp)
id("kotlin-parcelize")
}
fun readLocalProperty(name: String) = Properties().apply {
try {
load(rootProject.file("local.properties").reader())
} catch (ignored: java.io.IOException) {
}
}[name]
android {
namespace = "io.element.android.features.location.api"
defaultConfig {
resValue(
type = "string",
name = "maptiler_api_key",
value = System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY")
?: readLocalProperty("services.maptiler.apikey") as? String
?: ""
)
}
}
dependencies {

View file

@ -34,9 +34,8 @@ import androidx.compose.ui.unit.dp
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import io.element.android.features.location.api.internal.AttributionPlacement
import io.element.android.features.location.api.internal.StaticMapPlaceholder
import io.element.android.features.location.api.internal.buildStaticMapsApiUrl
import io.element.android.features.location.api.internal.staticMapUrl
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.text.toDp
@ -64,6 +63,7 @@ fun StaticMapView(
modifier = modifier,
contentAlignment = Alignment.Center
) {
val context = LocalContext.current
var retryHash by remember { mutableStateOf(0) }
val painter = rememberAsyncImagePainter(
model = if (constraints.isZero) {
@ -72,17 +72,16 @@ fun StaticMapView(
} else {
ImageRequest.Builder(LocalContext.current)
.data(
buildStaticMapsApiUrl(
staticMapUrl(
context = context,
lat = lat,
lon = lon,
desiredZoom = zoom,
zoom = zoom,
darkMode = darkMode,
attributionPlacement = AttributionPlacement.BottomLeft,
// Size the map based on DP rather than pixels, as otherwise the features and attribution
// end up being illegibly tiny on high density displays.
desiredWidth = constraints.maxWidth.toDp().value.toInt(),
desiredHeight = constraints.maxHeight.toDp().value.toInt(),
doubleScale = true,
width = constraints.maxWidth.toDp().value.toInt(),
height = constraints.maxHeight.toDp().value.toInt(),
)
)
.size(width = constraints.maxWidth, height = constraints.maxHeight)
@ -119,7 +118,6 @@ fun StaticMapView(
showProgress = painter.state is AsyncImagePainter.State.Loading,
contentDescription = contentDescription,
modifier = Modifier.size(width = maxWidth, height = maxHeight),
darkMode = darkMode,
onLoadMapClick = { retryHash++ }
)
}

View file

@ -0,0 +1,55 @@
/*
* 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 android.content.Context
import io.element.android.features.location.api.R
/**
* Provides the URL to an image that contains a statically-generated map of the given location.
*/
fun staticMapUrl(
context: Context,
lat: Double,
lon: Double,
zoom: Double,
width: Int,
height: Int,
darkMode: Boolean,
): String {
return "${baseUrl(darkMode)}/static/${lon},${lat},${zoom}/${width}x${height}@2x.webp?key=${context.apiKey}&attribution=bottomleft"
}
/**
* Provides the URL to a MapLibre style document, used for rendering dynamic maps.
*/
fun tileStyleUrl(
context: Context,
darkMode: Boolean,
): String {
return "${baseUrl(darkMode)}/style.json?key=${context.apiKey}"
}
private fun baseUrl(darkMode: Boolean) =
"https://api.maptiler.com/maps/" +
if (darkMode)
"dea61faf-292b-4774-9660-58fcef89a7f3"
else
"9bc819c8-e627-474a-a348-ec144fe3d810"
private val Context.apiKey: String
get() = getString(R.string.maptiler_api_key)

View file

@ -1,91 +0,0 @@
/*
* 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_2X = "@2x"
private const val STATIC_MAP_MAX_WIDTH_HEIGHT = 2048
private const val STATIC_MAP_MAX_ZOOM = 22.0
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"
}
internal enum class AttributionPlacement(val value: String) {
BottomRight("bottomright"),
BottomLeft("bottomleft"),
TopLeft("topleft"),
TopRight("topright"),
Hidden("false"),
}
/**
* 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,
doubleScale: Boolean,
attributionPlacement: AttributionPlacement,
): 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()
}
}
val mapId = if (darkMode) DARK_MAP_ID else LIGHT_MAP_ID
val scaleSuffix = if (doubleScale) STATIC_MAP_SCALE_2X else ""
return "$BASE_URL/maps/$mapId/static/${lon},${lat},${zoom}/${width}x${height}${scaleSuffix}.$STATIC_MAP_FORMAT" +
"?key=$API_KEY&attribution=${attributionPlacement.value}"
}

View file

@ -38,7 +38,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
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.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@ -46,17 +45,13 @@ internal fun StaticMapPlaceholder(
showProgress: Boolean,
contentDescription: String?,
modifier: Modifier = Modifier,
darkMode: Boolean = !ElementTheme.isLightTheme,
onLoadMapClick: () -> Unit,
) {
Box(
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(
id = if (darkMode) R.drawable.blurred_map_dark
else R.drawable.blurred_map_light
),
painter = painterResource(id = R.drawable.blurred_map),
contentDescription = contentDescription,
modifier = modifier,
contentScale = ContentScale.FillBounds,

View file

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Before After
Before After

View file

@ -1,117 +0,0 @@
/*
* 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 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,
doubleScale = false,
attributionPlacement = AttributionPlacement.BottomLeft,
)
).isEqualTo(
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200.webp" +
"?key=fU3vlMsMn4Jb6dnEIFsx&attribution=bottomleft"
)
}
@Test
fun `buildStaticMapsApiUrl builds dark mode url`() {
assertThat(
buildStaticMapsApiUrl(
lat = 1.234,
lon = 5.678,
desiredZoom = 1.2,
desiredWidth = 100,
desiredHeight = 200,
darkMode = true,
doubleScale = false,
attributionPlacement = AttributionPlacement.BottomLeft,
)
).isEqualTo(
"https://api.maptiler.com/maps/dea61faf-292b-4774-9660-58fcef89a7f3/static/5.678,1.234,1.2/100x200.webp" +
"?key=fU3vlMsMn4Jb6dnEIFsx&attribution=bottomleft"
)
}
@Test
fun `buildStaticMapsApiUrl builds double scale mode url`() {
assertThat(
buildStaticMapsApiUrl(
lat = 1.234,
lon = 5.678,
desiredZoom = 1.2,
desiredWidth = 100,
desiredHeight = 200,
darkMode = false,
doubleScale = true,
attributionPlacement = AttributionPlacement.BottomLeft,
)
).isEqualTo(
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200@2x.webp" +
"?key=fU3vlMsMn4Jb6dnEIFsx&attribution=bottomleft"
)
}
@Test
fun `buildStaticMapsApiUrl builds no attribution url`() {
assertThat(
buildStaticMapsApiUrl(
lat = 1.234,
lon = 5.678,
desiredZoom = 1.2,
desiredWidth = 100,
desiredHeight = 200,
darkMode = false,
doubleScale = false,
attributionPlacement = AttributionPlacement.Hidden,
)
).isEqualTo(
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200.webp" +
"?key=fU3vlMsMn4Jb6dnEIFsx&attribution=false"
)
}
@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,
doubleScale = false,
attributionPlacement = AttributionPlacement.BottomLeft,
)
).isEqualTo(
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,22.0/2048x1024.webp" +
"?key=fU3vlMsMn4Jb6dnEIFsx&attribution=bottomleft"
)
}
}

View file

@ -50,7 +50,7 @@ import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
import com.mapbox.mapboxsdk.style.layers.Property.ICON_ANCHOR_BOTTOM
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.internal.buildTileServerUrl
import io.element.android.features.location.api.internal.tileStyleUrl
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text
@ -102,7 +102,7 @@ fun MapView(
isCompassEnabled = false
isRotateGesturesEnabled = false
}
map.setStyle(buildTileServerUrl(darkMode = darkMode)) { style ->
map.setStyle(tileStyleUrl(context, darkMode)) { style ->
mapRefs = MapRefs(
map = map,
symbolManager = SymbolManager(mapView, map, style).apply {

View file

@ -41,6 +41,7 @@ import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
import io.element.android.features.messages.impl.report.ReportMessageNode
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@ -224,6 +225,20 @@ class MessagesFlowNode @AssistedInject constructor(
)
backstack.push(navTarget)
}
is TimelineItemAudioContent -> {
val mediaSource = event.content.audioSource
val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo(
name = event.content.body,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension
),
mediaSource = mediaSource,
thumbnailSource = null,
)
backstack.push(navTarget)
}
is TimelineItemLocationContent -> {
val navTarget = NavTarget.LocationViewer(
location = event.content.location,

View file

@ -43,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
@ -111,7 +112,7 @@ class MessagesPresenter @AssistedInject constructor(
val roomName by produceState(initialValue = room.displayName, key1 = syncUpdateFlow.value){
value = room.displayName
}
val roomAvatar by produceState(initialValue = room.avatarData(), key1 = syncUpdateFlow.value){
val roomAvatar by produceState(initialValue = room.avatarData(), key1 = syncUpdateFlow.value) {
value = room.avatarData()
}
var hasDismissedInviteDialog by rememberSaveable {
@ -250,28 +251,28 @@ class MessagesPresenter @AssistedInject constructor(
val textContent = messageSummaryFormatter.format(targetEvent)
val attachmentThumbnailInfo = when (targetEvent.content) {
is TimelineItemImageContent -> AttachmentThumbnailInfo(
mediaSource = targetEvent.content.mediaSource,
thumbnailSource = targetEvent.content.thumbnailSource,
textContent = targetEvent.content.body,
type = AttachmentThumbnailType.Image,
blurHash = targetEvent.content.blurhash,
)
is TimelineItemVideoContent -> AttachmentThumbnailInfo(
mediaSource = targetEvent.content.thumbnailSource,
thumbnailSource = targetEvent.content.thumbnailSource,
textContent = targetEvent.content.body,
type = AttachmentThumbnailType.Video,
blurHash = targetEvent.content.blurHash,
)
is TimelineItemFileContent -> AttachmentThumbnailInfo(
mediaSource = targetEvent.content.thumbnailSource,
thumbnailSource = targetEvent.content.thumbnailSource,
textContent = targetEvent.content.body,
type = AttachmentThumbnailType.File,
blurHash = null,
)
is TimelineItemAudioContent -> AttachmentThumbnailInfo(
textContent = targetEvent.content.body,
type = AttachmentThumbnailType.Audio,
)
is TimelineItemLocationContent -> AttachmentThumbnailInfo(
mediaSource = null,
textContent = null,
type = AttachmentThumbnailType.Location,
blurHash = null,
)
is TimelineItemTextBasedContent,
is TimelineItemRedactedContent,

View file

@ -56,6 +56,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
@ -246,8 +247,6 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
info = AttachmentThumbnailInfo(
type = AttachmentThumbnailType.Location,
textContent = stringResource(CommonStrings.common_shared_location),
mediaSource = null,
blurHash = null,
)
)
}
@ -258,9 +257,9 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
AttachmentThumbnail(
modifier = imageModifier,
info = AttachmentThumbnailInfo(
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.mediaSource,
textContent = textContent,
type = AttachmentThumbnailType.File,
type = AttachmentThumbnailType.Image,
blurHash = event.content.blurhash,
)
)
@ -272,7 +271,7 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
AttachmentThumbnail(
modifier = imageModifier,
info = AttachmentThumbnailInfo(
mediaSource = event.content.thumbnailSource,
thumbnailSource = event.content.thumbnailSource,
textContent = textContent,
type = AttachmentThumbnailType.Video,
blurHash = event.content.blurHash,
@ -286,10 +285,21 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
AttachmentThumbnail(
modifier = imageModifier,
info = AttachmentThumbnailInfo(
mediaSource = null,
thumbnailSource = event.content.thumbnailSource,
textContent = textContent,
type = AttachmentThumbnailType.File,
blurHash = null
)
)
}
content = { ContentForBody(event.content.body) }
}
is TimelineItemAudioContent -> {
icon = {
AttachmentThumbnail(
modifier = imageModifier,
info = AttachmentThumbnailInfo(
textContent = textContent,
type = AttachmentThumbnailType.Audio,
)
)
}

View file

@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Attachment
import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -47,7 +48,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem
@ -59,7 +59,9 @@ import io.element.android.features.messages.impl.media.helper.formatFileExtensio
import io.element.android.features.messages.impl.media.local.exoplayer.ExoPlayerWrapper
import io.element.android.features.messages.impl.media.local.pdf.PdfViewer
import io.element.android.features.messages.impl.media.local.pdf.rememberPdfViewerState
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.R
@ -103,6 +105,7 @@ fun LocalMediaView(
zoomableState = zoomableState,
modifier = modifier
)
//TODO handle audio with exoplayer
else -> MediaFileView(
localMediaViewState = localMediaViewState,
uri = localMedia?.uri,
@ -215,6 +218,7 @@ fun MediaFileView(
info: MediaInfo?,
modifier: Modifier = Modifier,
) {
val isAudio = info?.mimeType.isMimeTypeAudio().orFalse()
localMediaViewState.isReady = uri != null
Box(modifier = modifier.padding(horizontal = 8.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
@ -226,12 +230,12 @@ fun MediaFileView(
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.Attachment,
imageVector = if (isAudio) Icons.Outlined.GraphicEq else Icons.Outlined.Attachment,
contentDescription = null,
tint = MaterialTheme.colorScheme.background,
modifier = Modifier
.size(32.dp)
.rotate(-45f),
.rotate(if (isAudio) 0f else -45f),
)
}
if (info != null) {

View file

@ -29,7 +29,7 @@ data class MediaInfo(
) : Parcelable
fun anImageInfo(): MediaInfo = MediaInfo(
"an image file.jpg", MimeTypes.Jpeg, "4MB","jpg"
"an image file.jpg", MimeTypes.Jpeg, "4MB", "jpg"
)
fun aVideoInfo(): MediaInfo = MediaInfo(
@ -43,3 +43,7 @@ fun aPdfInfo(): MediaInfo = MediaInfo(
fun aFileInfo(): MediaInfo = MediaInfo(
"an apk file.apk", MimeTypes.Apk, "50MB", "apk"
)
fun anAudioInfo(): MediaInfo = MediaInfo(
"an audio file.mp3", MimeTypes.Mp3, "7MB", "mp3"
)

View file

@ -23,6 +23,7 @@ import io.element.android.features.messages.impl.media.local.MediaInfo
import io.element.android.features.messages.impl.media.local.aFileInfo
import io.element.android.features.messages.impl.media.local.aPdfInfo
import io.element.android.features.messages.impl.media.local.aVideoInfo
import io.element.android.features.messages.impl.media.local.anAudioInfo
import io.element.android.features.messages.impl.media.local.anImageInfo
import io.element.android.libraries.architecture.Async
@ -59,7 +60,17 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
LocalMedia(Uri.EMPTY, aFileInfo())
),
aFileInfo(),
)
),
aMediaViewerState(
Async.Loading(),
anAudioInfo(),
),
aMediaViewerState(
Async.Success(
LocalMedia(Uri.EMPTY, anAudioInfo())
),
anAudioInfo(),
),
)
}

View file

@ -51,7 +51,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R
@ -64,8 +63,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.matrix.api.core.EventId
@ -308,20 +307,11 @@ private fun JumpToBottomButton(
}
}
@Preview
@DayNightPreviews
@Composable
fun TimelineViewLightPreview(
fun TimelineViewPreview(
@PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent
) = ElementPreviewLight { ContentToPreview(content) }
@Preview
@Composable
fun TimelineViewDarkPreview(
@PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent
) = ElementPreviewDark { ContentToPreview(content) }
@Composable
private fun ContentToPreview(content: TimelineItemEventContent) {
) = ElementPreview {
val timelineItems = aTimelineItemList(content)
TimelineView(
state = aTimelineState(timelineItems),

View file

@ -51,8 +51,8 @@ import io.element.android.libraries.theme.ElementTheme
private val BUBBLE_RADIUS = 12.dp
private val BUBBLE_INCOMING_OFFSET = 16.dp
// Design says: The maximum width of a bubble is still 3/4 of the screen width
private const val BUBBLE_WIDTH_RATIO = 0.75f
// Design says: The maximum width of a bubble is still 3/4 of the screen width. But try with 85% now.
private const val BUBBLE_WIDTH_RATIO = 0.85f
@OptIn(ExperimentalFoundationApi::class)
@Composable

View file

@ -56,7 +56,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.constraintlayout.compose.ConstrainScope
import androidx.constraintlayout.compose.ConstraintLayout
@ -85,6 +84,7 @@ import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
@ -521,28 +521,29 @@ private fun ReplyToContent(
private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready) =
when (val type = inReplyTo.content.type) {
is ImageMessageType -> AttachmentThumbnailInfo(
mediaSource = type.info?.thumbnailSource,
thumbnailSource = type.info?.thumbnailSource,
textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.Image,
blurHash = type.info?.blurhash,
)
is VideoMessageType -> AttachmentThumbnailInfo(
mediaSource = type.info?.thumbnailSource,
thumbnailSource = type.info?.thumbnailSource,
textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.Video,
blurHash = type.info?.blurhash,
)
is FileMessageType -> AttachmentThumbnailInfo(
mediaSource = type.info?.thumbnailSource,
thumbnailSource = type.info?.thumbnailSource,
textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.File,
blurHash = null,
)
is LocationMessageType -> AttachmentThumbnailInfo(
mediaSource = null,
textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.Location,
blurHash = null,
)
is AudioMessageType -> AttachmentThumbnailInfo(
textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.Audio,
)
else -> null
}

View file

@ -0,0 +1,97 @@
/*
* 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.messages.impl.timeline.components.event
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentProvider
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
@Composable
fun TimelineItemAudioView(
content: TimelineItemAudioContent,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
) {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(ElementTheme.materialColors.background),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.GraphicEq,
contentDescription = null,
tint = ElementTheme.materialColors.primary,
modifier = Modifier
.size(16.dp),
)
}
Spacer(Modifier.width(8.dp))
Column {
Text(
text = content.body,
color = ElementTheme.materialColors.primary,
maxLines = 2,
style = ElementTheme.typography.fontBodyLgRegular,
overflow = TextOverflow.Ellipsis
)
Text(
text = content.fileExtensionAndSize + extraPadding.getStr(12.sp),
color = ElementTheme.materialColors.secondary,
style = ElementTheme.typography.fontBodySmRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@DayNightPreviews
@Composable
internal fun TimelineItemAudioViewPreview(@PreviewParameter(TimelineItemAudioContentProvider::class) content: TimelineItemAudioContent) =
ElementPreview {
TimelineItemAudioView(
content,
extraPadding = noExtraPadding,
)
}

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@ -80,6 +81,11 @@ fun TimelineItemEventContentView(
extraPadding = extraPadding,
modifier = modifier
)
is TimelineItemAudioContent -> TimelineItemAudioView(
content = content,
extraPadding = extraPadding,
modifier = modifier
)
is TimelineItemStateContent -> TimelineItemStateView(
content = content,
modifier = modifier

View file

@ -22,14 +22,13 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.location.api.StaticMapView
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
@ -57,17 +56,10 @@ fun TimelineItemLocationView(
}
}
@Preview
@DayNightPreviews
@Composable
internal fun TimelineItemLocationViewLightPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) =
ElementPreviewLight { ContentToPreview(content) }
internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) =
ElementPreview {
TimelineItemLocationView(content)
}
@Preview
@Composable
internal fun TimelineItemLocationViewDarkPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) =
ElementPreviewDark { ContentToPreview(content) }
@Composable
private fun ContentToPreview(content: TimelineItemLocationContent) {
TimelineItemLocationView(content)
}

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.timeline.factories.event
import io.element.android.features.location.api.Location
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@ -30,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.util.FileExtensionExtr
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.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
@ -99,6 +101,14 @@ class TimelineItemContentMessageFactory @Inject constructor(
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
)
}
is AudioMessageType -> TimelineItemAudioContent(
body = messageType.body,
audioSource = messageType.source,
duration = messageType.info?.duration?.toMillis() ?: 0L,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
)
is FileMessageType -> TimelineItemFileContent(
body = messageType.body,
thumbnailSource = messageType.info?.thumbnailSource,

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.timeline.groups
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
@ -52,6 +53,7 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean {
is TimelineItemImageContent,
is TimelineItemFileContent,
is TimelineItemVideoContent,
is TimelineItemAudioContent,
is TimelineItemLocationContent,
TimelineItemRedactedContent,
TimelineItemUnknownContent -> false

View file

@ -0,0 +1,33 @@
/*
* 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.
*/
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.features.messages.impl.media.helper.formatFileExtensionAndSize
import io.element.android.libraries.matrix.api.media.MediaSource
data class TimelineItemAudioContent(
val body: String,
val duration: Long,
val audioSource: MediaSource,
val mimeType: String,
val formattedFileSize: String,
val fileExtension: String,
) : TimelineItemEventContent {
val fileExtensionAndSize = formatFileExtensionAndSize(fileExtension, formattedFileSize)
override val type: String = "TimelineItemAudioContent"
}

View file

@ -0,0 +1,39 @@
/*
* 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.messages.impl.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaSource
open class TimelineItemAudioContentProvider : PreviewParameterProvider<TimelineItemAudioContent> {
override val values: Sequence<TimelineItemAudioContent>
get() = sequenceOf(
aTimelineItemAudioContent("A sound.mp3"),
aTimelineItemAudioContent("A bigger name sound.mp3"),
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit .mp3"),
)
}
fun aTimelineItemAudioContent(fileName: String = "A sound.mp3") = TimelineItemAudioContent(
body = fileName,
mimeType = MimeTypes.Pdf,
formattedFileSize = "100kB",
fileExtension = "mp3",
duration = 100,
audioSource = MediaSource(""),
)

View file

@ -26,7 +26,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
aTimelineItemEncryptedContent(),
aTimelineItemImageContent(),
aTimelineItemVideoContent(),
aTimelineItemFileContent("A file.pdf"),
aTimelineItemFileContent(),
aTimelineItemFileContent("A bigger file name which doesn't fit.pdf"),
aTimelineItemLocationContent(),
aTimelineItemLocationContent("Location description"),

View file

@ -23,7 +23,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineItemFileContent> {
override val values: Sequence<TimelineItemFileContent>
get() = sequenceOf(
aTimelineItemFileContent("A file.pdf"),
aTimelineItemFileContent(),
aTimelineItemFileContent("A bigger name file.pdf"),
aTimelineItemFileContent("An even bigger bigger bigger bigger bigger bigger bigger file name which doesn't fit .pdf"),
)
@ -31,7 +31,7 @@ open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineIt
fun aTimelineItemFileContent(fileName: String = "A file.pdf") = TimelineItemFileContent(
body = fileName,
thumbnailSource = MediaSource(url = ""),
thumbnailSource = null,
fileSource = MediaSource(url = ""),
mimeType = MimeTypes.Pdf,
formattedFileSize = "100kB",

View file

@ -31,7 +31,7 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineI
fun aTimelineItemVideoContent() = TimelineItemVideoContent(
body = "Video.mp4",
thumbnailSource = MediaSource(url = ""),
thumbnailSource = null,
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
aspectRatio = 0.5f,
duration = 100,

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.utils.messagesummary
import android.content.Context
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
@ -50,6 +51,7 @@ class MessageSummaryFormatterImpl @Inject constructor(
is TimelineItemImageContent -> context.getString(CommonStrings.common_image)
is TimelineItemVideoContent -> context.getString(CommonStrings.common_video)
is TimelineItemFileContent -> context.getString(CommonStrings.common_file)
is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio)
}
}
}

View file

@ -325,7 +325,7 @@ class MessageComposerPresenterTest {
Result.success(
MediaUploadInfo.Image(
file = File("/some/path"),
info = ImageInfo(
imageInfo = ImageInfo(
width = null,
height = null,
mimetype = null,
@ -358,7 +358,7 @@ class MessageComposerPresenterTest {
Result.success(
MediaUploadInfo.Video(
file = File("/some/path"),
info = VideoInfo(
videoInfo = VideoInfo(
width = null,
height = null,
mimetype = null,

View file

@ -16,7 +16,6 @@
package io.element.android.features.onboarding.impl
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@ -24,7 +23,6 @@ import androidx.compose.foundation.layout.fillMaxSize
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.material.icons.Icons
import androidx.compose.material.icons.filled.QrCode
@ -33,17 +31,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.aliasButtonText
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Icon
@ -85,10 +83,6 @@ fun OnBoardingView(
@Composable
private fun OnBoardingContent(modifier: Modifier = Modifier) {
// Note: having a night variant of R.drawable.onboarding_icon in the folder `drawable-night` is working
// at runtime, but is not in Android Studio Preview. So I prefer to handle this manually.
val isLight = ElementTheme.colors.isLight
val iconDrawableRes = if (isLight) R.drawable.onboarding_icon_light else R.drawable.onboarding_icon_dark
Box(
modifier = modifier.fillMaxSize(),
) {
@ -99,14 +93,9 @@ private fun OnBoardingContent(modifier: Modifier = Modifier) {
verticalBias = -0.4f
)
) {
// Dark and light icon does not have the same size, add padding to the smaller one
val imagePadding = if (isLight) 28.dp else 0.dp
Image(
modifier = Modifier
.size(278.dp)
.padding(imagePadding),
painter = painterResource(id = iconDrawableRes),
contentDescription = null,
ElementLogoAtom(
size = ElementLogoAtomSize.Large,
modifier = Modifier.padding(top = ElementLogoAtomSize.Large.shadowRadius / 2)
)
}
Box(
@ -200,17 +189,10 @@ private fun OnBoardingButtons(
}
}
@Preview
@DayNightPreviews
@Composable
internal fun OnBoardingScreenLightPreview(@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun OnBoardingScreenDarkPreview(@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: OnBoardingState) {
internal fun OnBoardingScreenPreview(
@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState
) = ElementPreview {
OnBoardingView(state)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View file

@ -44,6 +44,7 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.features.rageshake.api)
implementation(projects.features.analytics.api)
implementation(projects.features.ftue.api)
implementation(projects.libraries.matrixui)
implementation(projects.features.logout.api)
implementation(projects.services.toolbox.api)

View file

@ -22,6 +22,7 @@ import android.content.Context
import coil.Coil
import coil.annotation.ExperimentalCoilApi
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.preferences.impl.DefaultCacheService
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.ApplicationContext
@ -43,6 +44,7 @@ class DefaultClearCacheUseCase @Inject constructor(
private val coroutineDispatchers: CoroutineDispatchers,
private val defaultCacheIndexProvider: DefaultCacheService,
private val okHttpClient: Provider<OkHttpClient>,
private val ftueState: FtueState,
) : ClearCacheUseCase {
override suspend fun invoke() = withContext(coroutineDispatchers.io) {
// Clear Matrix cache
@ -56,6 +58,8 @@ class DefaultClearCacheUseCase @Inject constructor(
okHttpClient.get().cache?.delete()
// Clear app cache
context.cacheDir.deleteRecursively()
// Clear some settings
ftueState.reset()
// Ensure the app is restarted
defaultCacheIndexProvider.onClearedCache(matrixClient.sessionId)
}

View file

@ -76,8 +76,7 @@ class RoomDetailsNode @AssistedInject constructor(
val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) }
?: PermalinkBuilder.permalinkForRoomId(room.roomId)
permalinkResult.onSuccess { permalink ->
startSharePlainTextIntent(
context = context,
context.startSharePlainTextIntent(
activityResultLauncher = null,
chooserTitle = context.getString(R.string.screen_room_details_share_room_title),
text = permalink,
@ -91,8 +90,7 @@ class RoomDetailsNode @AssistedInject constructor(
private fun onShareMember(context: Context, member: RoomMember) {
val permalinkResult = PermalinkBuilder.permalinkForUser(member.userId)
permalinkResult.onSuccess { permalink ->
startSharePlainTextIntent(
context = context,
context.startSharePlainTextIntent(
activityResultLauncher = null,
chooserTitle = context.getString(R.string.screen_room_details_share_room_title),
text = permalink,

View file

@ -68,8 +68,7 @@ class RoomMemberDetailsNode @AssistedInject constructor(
fun onShareUser() {
val permalinkResult = PermalinkBuilder.permalinkForUser(inputs.roomMemberId)
permalinkResult.onSuccess { permalink ->
startSharePlainTextIntent(
context = context,
context.startSharePlainTextIntent(
activityResultLauncher = null,
chooserTitle = context.getString(R.string.screen_room_details_share_room_title),
text = permalink,

View file

@ -605,7 +605,7 @@ class RoomDetailsEditPresenterTest {
Result.success(
MediaUploadInfo.AnyFile(
file = processedFile,
info = mockk(),
fileInfo = mockk(),
)
)
)

View file

@ -145,7 +145,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.32"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.34"
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" }

View file

@ -76,9 +76,9 @@ fun Context.getApplicationLabel(packageName: String): String {
/**
* Return true it the user has enabled the do not disturb mode.
*/
fun isDoNotDisturbModeOn(context: Context): Boolean {
fun Context.isDoNotDisturbModeOn(): Boolean {
// We cannot use NotificationManagerCompat here.
val setting = context.getSystemService<NotificationManager>()!!.currentInterruptionFilter
val setting = getSystemService<NotificationManager>()!!.currentInterruptionFilter
return setting == NotificationManager.INTERRUPTION_FILTER_NONE ||
setting == NotificationManager.INTERRUPTION_FILTER_ALARMS
@ -92,10 +92,10 @@ fun isDoNotDisturbModeOn(context: Context): Boolean {
* will return false and the notification privacy will fallback to "LOW_DETAIL".
*/
@SuppressLint("BatteryLife")
fun requestDisablingBatteryOptimization(activity: Activity, activityResultLauncher: ActivityResultLauncher<Intent>) {
fun Context.requestDisablingBatteryOptimization(activityResultLauncher: ActivityResultLauncher<Intent>) {
val intent = Intent()
intent.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
intent.data = Uri.parse("package:" + activity.packageName)
intent.data = Uri.parse("package:$packageName")
activityResultLauncher.launch(intent)
}
@ -106,50 +106,48 @@ fun requestDisablingBatteryOptimization(activity: Activity, activityResultLaunch
/**
* Copy a text to the clipboard, and display a Toast when done.
*
* @param context the context
* @receiver the context
* @param text the text to copy
* @param toastMessage content of the toast message as a String resource. Null for no toast
*/
fun copyToClipboard(
context: Context,
fun Context.copyToClipboard(
text: CharSequence,
toastMessage: String? = null
) {
CopyToClipboardUseCase(context).execute(text)
toastMessage?.let { context.toast(it) }
CopyToClipboardUseCase(this).execute(text)
toastMessage?.let { toast(it) }
}
/**
* Shows notification settings for the current app.
* In android O will directly opens the notification settings, in lower version it will show the App settings
*/
fun startNotificationSettingsIntent(context: Context, activityResultLauncher: ActivityResultLauncher<Intent>) {
fun Context.startNotificationSettingsIntent(activityResultLauncher: ActivityResultLauncher<Intent>) {
val intent = Intent()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
intent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
} else {
intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.data = Uri.fromParts("package", context.packageName, null)
intent.data = Uri.fromParts("package", packageName, null)
}
activityResultLauncher.launch(intent)
}
fun openAppSettingsPage(
activity: Activity,
noActivityFoundMessage: String = activity.getString(R.string.error_no_compatible_app_found),
fun Context.openAppSettingsPage(
noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found),
) {
try {
activity.startActivity(
startActivity(
Intent().apply {
action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
data = Uri.fromParts("package", activity.packageName, null)
data = Uri.fromParts("package", packageName, null)
}
)
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(noActivityFoundMessage)
toast(noActivityFoundMessage)
}
}
@ -157,52 +155,49 @@ fun openAppSettingsPage(
* Shows notification system settings for the given channel id.
*/
@TargetApi(Build.VERSION_CODES.O)
fun startNotificationChannelSettingsIntent(activity: Activity, channelID: String) {
fun Activity.startNotificationChannelSettingsIntent(channelID: String) {
if (!supportNotificationChannels()) return
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, activity.packageName)
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, channelID)
}
activity.startActivity(intent)
startActivity(intent)
}
fun startAddGoogleAccountIntent(
context: Context,
fun Context.startAddGoogleAccountIntent(
activityResultLauncher: ActivityResultLauncher<Intent>,
noActivityFoundMessage: String = context.getString(R.string.error_no_compatible_app_found),
noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found),
) {
val intent = Intent(Settings.ACTION_ADD_ACCOUNT)
intent.putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf("com.google"))
try {
activityResultLauncher.launch(intent)
} catch (activityNotFoundException: ActivityNotFoundException) {
context.toast(noActivityFoundMessage)
toast(noActivityFoundMessage)
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun startInstallFromSourceIntent(
context: Context,
fun Context.startInstallFromSourceIntent(
activityResultLauncher: ActivityResultLauncher<Intent>,
noActivityFoundMessage: String = context.getString(R.string.error_no_compatible_app_found),
noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found),
) {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
.setData(Uri.parse(String.format("package:%s", context.packageName)))
.setData(Uri.parse(String.format("package:%s", packageName)))
try {
activityResultLauncher.launch(intent)
} catch (activityNotFoundException: ActivityNotFoundException) {
context.toast(noActivityFoundMessage)
toast(noActivityFoundMessage)
}
}
fun startSharePlainTextIntent(
context: Context,
fun Context.startSharePlainTextIntent(
activityResultLauncher: ActivityResultLauncher<Intent>?,
chooserTitle: String?,
text: String,
subject: String? = null,
extraTitle: String? = null,
noActivityFoundMessage: String = context.getString(R.string.error_no_compatible_app_found),
noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found),
) {
val share = Intent(Intent.ACTION_SEND)
share.type = "text/plain"
@ -220,17 +215,16 @@ fun startSharePlainTextIntent(
if (activityResultLauncher != null) {
activityResultLauncher.launch(intent)
} else {
context.startActivity(intent)
startActivity(intent)
}
} catch (activityNotFoundException: ActivityNotFoundException) {
context.toast(noActivityFoundMessage)
toast(noActivityFoundMessage)
}
}
fun startImportTextFromFileIntent(
context: Context,
fun Context.startImportTextFromFileIntent(
activityResultLauncher: ActivityResultLauncher<Intent>,
noActivityFoundMessage: String = context.getString(R.string.error_no_compatible_app_found),
noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found),
) {
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
type = "text/plain"
@ -238,7 +232,7 @@ fun startImportTextFromFileIntent(
try {
activityResultLauncher.launch(intent)
} catch (activityNotFoundException: ActivityNotFoundException) {
context.toast(noActivityFoundMessage)
toast(noActivityFoundMessage)
}
}

View file

@ -38,6 +38,7 @@ object MimeTypes {
const val Audio = "audio/*"
const val Ogg = "audio/ogg"
const val Mp3 = "audio/mp3"
const val PlainText = "text/plain"

View file

@ -37,8 +37,7 @@ class InviteFriendsUseCase @Inject constructor(
permalinkResult.fold(
onSuccess = { permalink ->
val appName = buildMeta.applicationName
startSharePlainTextIntent(
context = activity,
activity.startSharePlainTextIntent(
activityResultLauncher = null,
chooserTitle = stringProvider.getString(CommonStrings.action_invite_friends),
text = stringProvider.getString(CommonStrings.invite_friends_text, appName, permalink),

View file

@ -53,82 +53,72 @@ import io.element.android.libraries.theme.ElementTheme
fun ElementLogoAtom(
size: ElementLogoAtomSize,
modifier: Modifier = Modifier,
darkTheme: Boolean = isSystemInDarkTheme(),
) {
val outerSize = when (size) {
ElementLogoAtomSize.Large -> 158.dp
ElementLogoAtomSize.Medium -> 120.dp
}
val logoSize = when (size) {
ElementLogoAtomSize.Large -> 110.dp
ElementLogoAtomSize.Medium -> 83.5.dp
}
val cornerRadius = when(size) {
ElementLogoAtomSize.Large -> 44.dp
ElementLogoAtomSize.Medium -> 33.dp
}
val borderWidth = when (size) {
ElementLogoAtomSize.Large -> 1.dp
ElementLogoAtomSize.Medium -> 0.38.dp
}
val blur = if (isSystemInDarkTheme()) {
160.dp
} else {
24.dp
}
val blur = if (darkTheme) 160.dp else 24.dp
//box-shadow: 0px 6.075949668884277px 24.30379867553711px 0px #1B1D2280;
val shadowColor = if (isSystemInDarkTheme()) {
Color.Black.copy(alpha = 0.4f)
} else {
Color(0x401B1D22)
}
val backgroundColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.4f)
val borderColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.8f) else Color.White.copy(alpha = 0.4f)
val shadowColor = if (darkTheme) size.shadowColorDark else size.shadowColorLight
val backgroundColor = if (darkTheme) Color.White.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.4f)
val borderColor = if (darkTheme) Color.White.copy(alpha = 0.8f) else Color.White.copy(alpha = 0.4f)
Box(
modifier = modifier
.size(outerSize)
.border(borderWidth, borderColor, RoundedCornerShape(cornerRadius)),
.size(size.outerSize)
.border(size.borderWidth, borderColor, RoundedCornerShape(size.cornerRadius)),
contentAlignment = Alignment.Center,
) {
Box(
Modifier
.size(outerSize)
.size(size.outerSize)
.shapeShadow(
color = shadowColor,
cornerRadius = cornerRadius,
blurRadius = 32.dp,
cornerRadius = size.cornerRadius,
blurRadius = size.shadowRadius,
offsetY = 8.dp,
)
)
Box(
Modifier
.clip(RoundedCornerShape(cornerRadius))
.size(outerSize)
.clip(RoundedCornerShape(size.cornerRadius))
.size(size.outerSize)
.background(backgroundColor)
.blur(blur)
)
Image(
modifier = Modifier.size(logoSize),
modifier = Modifier.size(size.logoSize),
painter = painterResource(id = R.drawable.element_logo),
contentDescription = null
)
}
}
enum class ElementLogoAtomSize {
Medium,
Large
}
sealed class ElementLogoAtomSize(
val outerSize: Dp,
val logoSize: Dp,
val cornerRadius: Dp,
val borderWidth: Dp,
val shadowColorDark: Color,
val shadowColorLight: Color,
val shadowRadius: Dp,
) {
object Medium : ElementLogoAtomSize(
outerSize = 120.dp,
logoSize = 83.5.dp,
cornerRadius = 33.dp,
borderWidth = 0.38.dp,
shadowColorDark = Color.Black.copy(alpha = 0.4f),
shadowColorLight = Color(0x401B1D22),
shadowRadius = 32.dp,
)
@Composable
@DayNightPreviews
internal fun ElementLogoAtomPreview() {
ElementPreview {
Box(
Modifier
.size(170.dp)
.background(ElementTheme.colors.bgSubtlePrimary))
ElementLogoAtom(ElementLogoAtomSize.Large)
}
object Large : ElementLogoAtomSize(
outerSize = 158.dp,
logoSize = 110.dp,
cornerRadius = 44.dp,
borderWidth = 0.5.dp,
shadowColorDark = Color.Black,
shadowColorLight = Color(0x801B1D22),
shadowRadius = 60.dp,
)
}
fun Modifier.shapeShadow(
@ -168,3 +158,29 @@ fun Modifier.shapeShadow(
}
}
)
@Composable
@DayNightPreviews
internal fun ElementLogoAtomMediumPreview() {
ContentToPreview(ElementLogoAtomSize.Medium)
}
@Composable
@DayNightPreviews
internal fun ElementLogoAtomLargePreview() {
ContentToPreview(ElementLogoAtomSize.Large)
}
@Composable
private fun ContentToPreview(elementLogoAtomSize: ElementLogoAtomSize) {
ElementPreview {
Box(
Modifier
.size(elementLogoAtomSize.outerSize + elementLogoAtomSize.shadowRadius * 2)
.background(ElementTheme.colors.bgSubtlePrimary),
contentAlignment = Alignment.Center
) {
ElementLogoAtom(elementLogoAtomSize)
}
}
}

View file

@ -31,6 +31,7 @@ import io.element.android.libraries.designsystem.atomic.atoms.InfoListItemMolecu
import io.element.android.libraries.designsystem.atomic.atoms.InfoListItemPosition
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList
@Composable
@ -54,7 +55,13 @@ fun InfoListOrganism(
else -> InfoListItemPosition.Middle
}
InfoListItemMolecule(
message = { Text(item.message, style = textStyle) },
message = {
Text(
text = item.message,
style = textStyle,
color = ElementTheme.colors.textPrimary,
)
},
icon = {
if (item.iconId != null) {
Icon(resourceId = item.iconId, contentDescription = null, tint = iconTint)

View file

@ -28,11 +28,10 @@ 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.R
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
@ -52,10 +51,6 @@ fun OnBoardingPage(
footer: @Composable () -> Unit = {},
content: @Composable () -> Unit = {},
) {
// Note: having a night variant of R.drawable.onboarding_bg in the folder `drawable-night` is working
// at runtime, but is not in Android Studio Preview. So I prefer to handle this manually.
val isLight = ElementTheme.colors.isLight
val bgDrawableRes = if (isLight) R.drawable.onboarding_bg_light else R.drawable.onboarding_bg_dark
Box(
modifier = modifier
.fillMaxSize()
@ -64,7 +59,7 @@ fun OnBoardingPage(
Image(
modifier = Modifier
.fillMaxSize(),
painter = painterResource(id = bgDrawableRes),
painter = painterResource(id = R.drawable.onboarding_bg),
contentScale = ContentScale.Crop,
contentDescription = null,
)
@ -92,18 +87,9 @@ fun OnBoardingPage(
}
}
@Preview
@DayNightPreviews
@Composable
internal fun OnBoardingPageLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun OnBoardingPageDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
internal fun OnBoardingPagePreview() = ElementPreview {
OnBoardingPage(
content = {
Box(

View file

@ -20,14 +20,17 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@ -37,21 +40,32 @@ import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.CommonStrings
import timber.log.Timber
@Composable
fun ProgressDialog(
modifier: Modifier = Modifier,
text: String? = null,
type: ProgressDialogType = ProgressDialogType.Indeterminate,
onDismiss: () -> Unit = {},
isCancellable: Boolean = false,
onDismissRequest: () -> Unit = {},
) {
DisposableEffect(Unit) {
onDispose {
Timber.v("OnDispose progressDialog")
}
}
Dialog(
onDismissRequest = onDismiss,
onDismissRequest = onDismissRequest,
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
) {
ProgressDialogContent(
modifier = modifier,
text = text,
isCancellable = isCancellable,
onCancelClicked = onDismissRequest,
progressIndicator = {
when (type) {
is ProgressDialogType.Indeterminate -> {
@ -81,6 +95,8 @@ sealed interface ProgressDialogType {
private fun ProgressDialogContent(
modifier: Modifier = Modifier,
text: String? = null,
isCancellable: Boolean = false,
onCancelClicked: () -> Unit = {},
progressIndicator: @Composable () -> Unit = {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary
@ -107,6 +123,17 @@ private fun ProgressDialogContent(
color = MaterialTheme.colorScheme.primary,
)
}
if (isCancellable) {
Spacer(modifier = Modifier.height(24.dp))
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.BottomEnd
) {
TextButton(onClick = onCancelClicked) {
Text(stringResource(id = CommonStrings.action_cancel))
}
}
}
}
}
}
@ -118,6 +145,6 @@ internal fun ProgressDialogPreview() = ElementThemedPreview { ContentToPreview()
@Composable
private fun ContentToPreview() {
DialogPreview {
ProgressDialogContent(text = "test dialog content")
ProgressDialogContent(text = "test dialog content", isCancellable = true)
}
}

View file

Before

Width:  |  Height:  |  Size: 388 KiB

After

Width:  |  Height:  |  Size: 388 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 223 KiB

Before After
Before After

View file

@ -21,5 +21,5 @@ import java.time.Duration
data class AudioInfo(
val duration: Duration?,
val size: Long?,
val mimeType: String?,
val mimetype: String?,
)

View file

@ -95,9 +95,7 @@ interface MatrixRoom : Closeable {
suspend fun leave(): Result<Unit>
suspend fun acceptInvitation(): Result<Unit>
suspend fun rejectInvitation(): Result<Unit>
suspend fun join(): Result<Unit>
suspend fun inviteUserById(id: UserId): Result<Unit>

View file

@ -22,11 +22,11 @@ import org.matrix.rustcomponents.sdk.AudioInfo as RustAudioInfo
fun RustAudioInfo.map(): AudioInfo = AudioInfo(
duration = duration,
size = size?.toLong(),
mimeType = mimetype
mimetype = mimetype
)
fun AudioInfo.map(): RustAudioInfo = RustAudioInfo(
duration = duration,
size = size?.toULong(),
mimetype = mimeType,
mimetype = mimetype,
)

View file

@ -256,15 +256,9 @@ class RustMatrixRoom(
}
}
override suspend fun acceptInvitation(): Result<Unit> = withContext(roomDispatcher) {
override suspend fun join(): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.acceptInvitation()
}
}
override suspend fun rejectInvitation(): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.rejectInvitation()
innerRoom.join()
}
}

View file

@ -63,8 +63,7 @@ class FakeMatrixRoom(
private var userDisplayNameResult = Result.success<String?>(null)
private var userAvatarUrlResult = Result.success<String?>(null)
private var updateMembersResult: Result<Unit> = Result.success(Unit)
private var acceptInviteResult = Result.success(Unit)
private var rejectInviteResult = Result.success(Unit)
private var joinRoomResult = Result.success(Unit)
private var inviteUserResult = Result.success(Unit)
private var canInviteResult = Result.success(true)
private val canSendStateResults = mutableMapOf<StateEventType, Result<Boolean>>()
@ -101,11 +100,6 @@ class FakeMatrixRoom(
var sendLocationCount: Int = 0
private set
var isInviteAccepted: Boolean = false
private set
var isInviteRejected: Boolean = false
private set
var invitedUserId: UserId? = null
private set
@ -196,16 +190,11 @@ class FakeMatrixRoom(
return Result.success(Unit)
}
override suspend fun leave(): Result<Unit> = leaveRoomError?.let { Result.failure(it) } ?: Result.success(Unit)
override suspend fun leave(): Result<Unit> =
leaveRoomError?.let { Result.failure(it) } ?: Result.success(Unit)
override suspend fun acceptInvitation(): Result<Unit> {
isInviteAccepted = true
return acceptInviteResult
}
override suspend fun rejectInvitation(): Result<Unit> {
isInviteRejected = true
return rejectInviteResult
override suspend fun join(): Result<Unit> {
return joinRoomResult
}
override suspend fun inviteUserById(id: UserId): Result<Unit> = simulateLongTask {
@ -316,12 +305,8 @@ class FakeMatrixRoom(
userAvatarUrlResult = avatarUrl
}
fun givenAcceptInviteResult(result: Result<Unit>) {
acceptInviteResult = result
}
fun givenRejectInviteResult(result: Result<Unit>) {
rejectInviteResult = result
fun givenJoinRoomResult(result: Result<Unit>) {
joinRoomResult = result
}
fun givenInviteUserResult(result: Result<Unit>) {

View file

@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Attachment
import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.material.icons.outlined.VideoCameraBack
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@ -44,9 +45,9 @@ fun AttachmentThumbnail(
thumbnailSize: Long = 32L,
backgroundColor: Color = MaterialTheme.colorScheme.surface,
) {
if (info.mediaSource != null) {
if (info.thumbnailSource != null) {
val mediaRequestData = MediaRequestData(
source = info.mediaSource,
source = info.thumbnailSource,
kind = MediaRequestData.Kind.Thumbnail(thumbnailSize),
)
BlurHashAsyncImage(
@ -68,6 +69,12 @@ fun AttachmentThumbnail(
contentDescription = info.textContent,
)
}
AttachmentThumbnailType.Audio -> {
Icon(
imageVector = Icons.Outlined.GraphicEq,
contentDescription = info.textContent,
)
}
AttachmentThumbnailType.File -> {
Icon(
imageVector = Icons.Outlined.Attachment,
@ -88,13 +95,13 @@ fun AttachmentThumbnail(
@Parcelize
enum class AttachmentThumbnailType: Parcelable {
Image, Video, File, Location
Image, Video, File, Audio, Location
}
@Parcelize
data class AttachmentThumbnailInfo(
val mediaSource: MediaSource?,
val textContent: String?,
val type: AttachmentThumbnailType?,
val blurHash: String?,
val type: AttachmentThumbnailType,
val thumbnailSource: MediaSource? = null,
val textContent: String? = null,
val blurHash: String? = null,
): Parcelable

View file

@ -46,36 +46,43 @@ class MediaSender @Inject constructor(
}
private suspend fun MatrixRoom.sendMedia(
info: MediaUploadInfo,
uploadInfo: MediaUploadInfo,
progressCallback: ProgressCallback?
): Result<Unit> {
return when (info) {
return when (uploadInfo) {
is MediaUploadInfo.Image -> {
sendImage(
file = info.file,
thumbnailFile = info.thumbnailFile,
imageInfo = info.info,
file = uploadInfo.file,
thumbnailFile = uploadInfo.thumbnailFile,
imageInfo = uploadInfo.imageInfo,
progressCallback = progressCallback
)
}
is MediaUploadInfo.Video -> {
sendVideo(
file = info.file,
thumbnailFile = info.thumbnailFile,
videoInfo = info.info,
file = uploadInfo.file,
thumbnailFile = uploadInfo.thumbnailFile,
videoInfo = uploadInfo.videoInfo,
progressCallback = progressCallback
)
}
is MediaUploadInfo.Audio -> {
sendAudio(
file = uploadInfo.file,
audioInfo = uploadInfo.audioInfo,
progressCallback = progressCallback
)
}
is MediaUploadInfo.AnyFile -> {
sendFile(
file = info.file,
fileInfo = info.info,
file = uploadInfo.file,
fileInfo = uploadInfo.fileInfo,
progressCallback = progressCallback
)
}
else -> Result.failure(IllegalStateException("Unexpected MediaUploadInfo format: $info"))
else -> Result.failure(IllegalStateException("Unexpected MediaUploadInfo format: $uploadInfo"))
}
}
}

View file

@ -26,8 +26,8 @@ sealed interface MediaUploadInfo {
val file: File
data class Image(override val file: File, val info: ImageInfo, val thumbnailFile: File) : MediaUploadInfo
data class Video(override val file: File, val info: VideoInfo, val thumbnailFile: File) : MediaUploadInfo
data class Audio(override val file: File, val info: AudioInfo) : MediaUploadInfo
data class AnyFile(override val file: File, val info: FileInfo) : MediaUploadInfo
data class Image(override val file: File, val imageInfo: ImageInfo, val thumbnailFile: File) : MediaUploadInfo
data class Video(override val file: File, val videoInfo: VideoInfo, val thumbnailFile: File) : MediaUploadInfo
data class Audio(override val file: File, val audioInfo: AudioInfo) : MediaUploadInfo
data class AnyFile(override val file: File, val fileInfo: FileInfo) : MediaUploadInfo
}

View file

@ -133,7 +133,7 @@ class AndroidMediaPreProcessor @Inject constructor(
removeSensitiveImageMetadata(compressionResult.file)
return MediaUploadInfo.Image(
file = compressionResult.file,
info = imageInfo,
imageInfo = imageInfo,
thumbnailFile = thumbnailResult.file
)
}
@ -156,7 +156,7 @@ class AndroidMediaPreProcessor @Inject constructor(
removeSensitiveImageMetadata(file)
return MediaUploadInfo.Image(
file = file,
info = imageInfo,
imageInfo = imageInfo,
thumbnailFile = thumbnailResult.file
)
}
@ -184,7 +184,7 @@ class AndroidMediaPreProcessor @Inject constructor(
val videoInfo = extractVideoMetadata(resultFile, mimeType, thumbnailInfo)
return MediaUploadInfo.Video(
file = resultFile,
info = videoInfo,
videoInfo = videoInfo,
thumbnailFile = thumbnailInfo.file
)
}
@ -196,7 +196,7 @@ class AndroidMediaPreProcessor @Inject constructor(
val info = AudioInfo(
duration = extractDuration(),
size = file.length(),
mimeType = mimeType,
mimetype = mimeType,
)
MediaUploadInfo.Audio(file, info)

View file

@ -165,15 +165,15 @@ class NotificationChannels @Inject constructor(
private fun supportNotificationChannels() = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
fun openSystemSettingsForSilentCategory(activity: Activity) {
startNotificationChannelSettingsIntent(activity, SILENT_NOTIFICATION_CHANNEL_ID)
activity.startNotificationChannelSettingsIntent(SILENT_NOTIFICATION_CHANNEL_ID)
}
fun openSystemSettingsForNoisyCategory(activity: Activity) {
startNotificationChannelSettingsIntent(activity, NOISY_NOTIFICATION_CHANNEL_ID)
activity.startNotificationChannelSettingsIntent(NOISY_NOTIFICATION_CHANNEL_ID)
}
fun openSystemSettingsForCallCategory(activity: Activity) {
startNotificationChannelSettingsIntent(activity, CALL_NOTIFICATION_CHANNEL_ID)
activity.startNotificationChannelSettingsIntent(CALL_NOTIFICATION_CHANNEL_ID)
}
}
}

View file

@ -483,7 +483,7 @@ fun TextComposerReplyPreview() = ElementPreview {
senderName = "Alice",
eventId = EventId("$1234"),
attachmentThumbnailInfo = AttachmentThumbnailInfo(
mediaSource = MediaSource("https://domain.com/image.jpg"),
thumbnailSource = MediaSource("https://domain.com/image.jpg"),
textContent = "image.jpg",
type = AttachmentThumbnailType.Image,
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
@ -501,7 +501,7 @@ fun TextComposerReplyPreview() = ElementPreview {
senderName = "Alice",
eventId = EventId("$1234"),
attachmentThumbnailInfo = AttachmentThumbnailInfo(
mediaSource = MediaSource("https://domain.com/video.mp4"),
thumbnailSource = MediaSource("https://domain.com/video.mp4"),
textContent = "video.mp4",
type = AttachmentThumbnailType.Video,
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
@ -519,7 +519,7 @@ fun TextComposerReplyPreview() = ElementPreview {
senderName = "Alice",
eventId = EventId("$1234"),
attachmentThumbnailInfo = AttachmentThumbnailInfo(
mediaSource = null,
thumbnailSource = null,
textContent = "logs.txt",
type = AttachmentThumbnailType.File,
blurHash = null,
@ -537,7 +537,7 @@ fun TextComposerReplyPreview() = ElementPreview {
senderName = "Alice",
eventId = EventId("$1234"),
attachmentThumbnailInfo = AttachmentThumbnailInfo(
mediaSource = null,
thumbnailSource = null,
textContent = null,
type = AttachmentThumbnailType.Location,
blurHash = null,

View file

@ -58,4 +58,9 @@ interface AnalyticsService: AnalyticsTracker, ErrorTracker {
* To be called when a session is destroyed.
*/
suspend fun onSignOut()
/**
* Reset the analytics service (will ask for user consent again).
*/
suspend fun reset()
}

View file

@ -78,6 +78,10 @@ class DefaultAnalyticsService @Inject constructor(
analyticsStore.setDidAskUserConsent()
}
override suspend fun reset() {
analyticsStore.setDidAskUserConsent(false)
}
override fun getAnalyticsId(): Flow<String> {
return analyticsStore.analyticsIdFlow
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:76a68f2fc93894d6f9d9caea02546766c55664c0e53ba9506c6c32df058f5823
size 303608
oid sha256:16de62092834bf803c8165e974f45e14ccfc0128a3e74295a58eef965abc10c5
size 301336

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f24bb3e40dd8c02037bd9d4523726ec0a0b1a283d23a8ca143973b0e9ee673c6
size 408318
oid sha256:6838e81cc5f2755ff76de7254e2c8bb445b76662d7ba9b4c83443b2c2ed03029
size 406044

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a667744cd675695d67f5b402101f2ce72318fcc9311933300a83cf86bf3b2c19
size 47473
oid sha256:e456ca95ee33cf14cac839b2b57879d17ca47b156da65c8a870882a90ef2c84c
size 40664

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:64a8da63cd27373a61e788cdf569e05f1608ab32bebffd2651755d5a3345c37c
size 45752
oid sha256:66a7d55b40a9d5d7bc02f531bd3c80a1ba96e24a619c123f68549df355019558
size 38989

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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