Merge branch 'develop' into feature/fga/power_level

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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 @Composable
private fun CheckIcon(modifier: Modifier = Modifier) { private fun CheckIcon(modifier: Modifier = Modifier) {
Icon( Icon(
modifier = Modifier modifier = modifier
.size(20.dp) .size(20.dp)
.background(color = MaterialTheme.colorScheme.background, shape = CircleShape) .background(color = MaterialTheme.colorScheme.background, shape = CircleShape)
.padding(2.dp), .padding(2.dp),

View file

@ -67,4 +67,8 @@ class FakeAnalyticsService(
override fun trackError(throwable: Throwable) { 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 { interface FtueState {
val shouldDisplayFlow: StateFlow<Boolean> val shouldDisplayFlow: StateFlow<Boolean>
suspend fun reset()
} }

View file

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

View file

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

View file

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

View file

@ -27,4 +27,8 @@ class FakeWelcomeState : WelcomeScreenState {
override fun setWelcomeScreenShown() { override fun setWelcomeScreenShown() {
isWelcomeScreenNeeded = false 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 { private fun CoroutineScope.acceptInvite(roomId: RoomId, acceptedAction: MutableState<Async<RoomId>>) = launch {
suspend { suspend {
client.getRoom(roomId)?.use { client.getRoom(roomId)?.use {
it.acceptInvitation().getOrThrow() it.join().getOrThrow()
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId) notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
analyticsService.capture(it.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite)) 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 { private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState<Async<Unit>>) = launch {
suspend { suspend {
client.getRoom(roomId)?.use { client.getRoom(roomId)?.use {
it.rejectInvitation().getOrThrow() it.leave().getOrThrow()
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId) notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
} }
Unit Unit

View file

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

View file

@ -14,14 +14,33 @@
* limitations under the License. * limitations under the License.
*/ */
import java.util.Properties
plugins { plugins {
id("io.element.android-compose-library") id("io.element.android-compose-library")
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
id("kotlin-parcelize") id("kotlin-parcelize")
} }
fun readLocalProperty(name: String) = Properties().apply {
try {
load(rootProject.file("local.properties").reader())
} catch (ignored: java.io.IOException) {
}
}[name]
android { android {
namespace = "io.element.android.features.location.api" 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 { dependencies {

View file

@ -34,9 +34,8 @@ import androidx.compose.ui.unit.dp
import coil.compose.AsyncImagePainter import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest 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.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.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.text.toDp
@ -64,6 +63,7 @@ fun StaticMapView(
modifier = modifier, modifier = modifier,
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
val context = LocalContext.current
var retryHash by remember { mutableStateOf(0) } var retryHash by remember { mutableStateOf(0) }
val painter = rememberAsyncImagePainter( val painter = rememberAsyncImagePainter(
model = if (constraints.isZero) { model = if (constraints.isZero) {
@ -72,17 +72,16 @@ fun StaticMapView(
} else { } else {
ImageRequest.Builder(LocalContext.current) ImageRequest.Builder(LocalContext.current)
.data( .data(
buildStaticMapsApiUrl( staticMapUrl(
context = context,
lat = lat, lat = lat,
lon = lon, lon = lon,
desiredZoom = zoom, zoom = zoom,
darkMode = darkMode, darkMode = darkMode,
attributionPlacement = AttributionPlacement.BottomLeft,
// Size the map based on DP rather than pixels, as otherwise the features and attribution // Size the map based on DP rather than pixels, as otherwise the features and attribution
// end up being illegibly tiny on high density displays. // end up being illegibly tiny on high density displays.
desiredWidth = constraints.maxWidth.toDp().value.toInt(), width = constraints.maxWidth.toDp().value.toInt(),
desiredHeight = constraints.maxHeight.toDp().value.toInt(), height = constraints.maxHeight.toDp().value.toInt(),
doubleScale = true,
) )
) )
.size(width = constraints.maxWidth, height = constraints.maxHeight) .size(width = constraints.maxWidth, height = constraints.maxHeight)
@ -119,7 +118,6 @@ fun StaticMapView(
showProgress = painter.state is AsyncImagePainter.State.Loading, showProgress = painter.state is AsyncImagePainter.State.Loading,
contentDescription = contentDescription, contentDescription = contentDescription,
modifier = Modifier.size(width = maxWidth, height = maxHeight), modifier = Modifier.size(width = maxWidth, height = maxHeight),
darkMode = darkMode,
onLoadMapClick = { retryHash++ } 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.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
@ -46,17 +45,13 @@ internal fun StaticMapPlaceholder(
showProgress: Boolean, showProgress: Boolean,
contentDescription: String?, contentDescription: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
darkMode: Boolean = !ElementTheme.isLightTheme,
onLoadMapClick: () -> Unit, onLoadMapClick: () -> Unit,
) { ) {
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Image( Image(
painter = painterResource( painter = painterResource(id = R.drawable.blurred_map),
id = if (darkMode) R.drawable.blurred_map_dark
else R.drawable.blurred_map_light
),
contentDescription = contentDescription, contentDescription = contentDescription,
modifier = modifier, modifier = modifier,
contentScale = ContentScale.FillBounds, 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.plugins.annotation.SymbolOptions
import com.mapbox.mapboxsdk.style.layers.Property.ICON_ANCHOR_BOTTOM 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.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.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
@ -102,7 +102,7 @@ fun MapView(
isCompassEnabled = false isCompassEnabled = false
isRotateGesturesEnabled = false isRotateGesturesEnabled = false
} }
map.setStyle(buildTileServerUrl(darkMode = darkMode)) { style -> map.setStyle(tileStyleUrl(context, darkMode)) { style ->
mapRefs = MapRefs( mapRefs = MapRefs(
map = map, map = map,
symbolManager = SymbolManager(mapView, map, style).apply { 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.report.ReportMessageNode
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode 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.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.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@ -224,6 +225,20 @@ class MessagesFlowNode @AssistedInject constructor(
) )
backstack.push(navTarget) 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 -> { is TimelineItemLocationContent -> {
val navTarget = NavTarget.LocationViewer( val navTarget = NavTarget.LocationViewer(
location = event.content.location, 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.customreaction.CustomReactionPresenter
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter 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.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.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent 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.TimelineItemImageContent
@ -111,7 +112,7 @@ class MessagesPresenter @AssistedInject constructor(
val roomName by produceState(initialValue = room.displayName, key1 = syncUpdateFlow.value){ val roomName by produceState(initialValue = room.displayName, key1 = syncUpdateFlow.value){
value = room.displayName 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() value = room.avatarData()
} }
var hasDismissedInviteDialog by rememberSaveable { var hasDismissedInviteDialog by rememberSaveable {
@ -250,28 +251,28 @@ class MessagesPresenter @AssistedInject constructor(
val textContent = messageSummaryFormatter.format(targetEvent) val textContent = messageSummaryFormatter.format(targetEvent)
val attachmentThumbnailInfo = when (targetEvent.content) { val attachmentThumbnailInfo = when (targetEvent.content) {
is TimelineItemImageContent -> AttachmentThumbnailInfo( is TimelineItemImageContent -> AttachmentThumbnailInfo(
mediaSource = targetEvent.content.mediaSource, thumbnailSource = targetEvent.content.thumbnailSource,
textContent = targetEvent.content.body, textContent = targetEvent.content.body,
type = AttachmentThumbnailType.Image, type = AttachmentThumbnailType.Image,
blurHash = targetEvent.content.blurhash, blurHash = targetEvent.content.blurhash,
) )
is TimelineItemVideoContent -> AttachmentThumbnailInfo( is TimelineItemVideoContent -> AttachmentThumbnailInfo(
mediaSource = targetEvent.content.thumbnailSource, thumbnailSource = targetEvent.content.thumbnailSource,
textContent = targetEvent.content.body, textContent = targetEvent.content.body,
type = AttachmentThumbnailType.Video, type = AttachmentThumbnailType.Video,
blurHash = targetEvent.content.blurHash, blurHash = targetEvent.content.blurHash,
) )
is TimelineItemFileContent -> AttachmentThumbnailInfo( is TimelineItemFileContent -> AttachmentThumbnailInfo(
mediaSource = targetEvent.content.thumbnailSource, thumbnailSource = targetEvent.content.thumbnailSource,
textContent = targetEvent.content.body, textContent = targetEvent.content.body,
type = AttachmentThumbnailType.File, type = AttachmentThumbnailType.File,
blurHash = null, )
is TimelineItemAudioContent -> AttachmentThumbnailInfo(
textContent = targetEvent.content.body,
type = AttachmentThumbnailType.Audio,
) )
is TimelineItemLocationContent -> AttachmentThumbnailInfo( is TimelineItemLocationContent -> AttachmentThumbnailInfo(
mediaSource = null,
textContent = null,
type = AttachmentThumbnailType.Location, type = AttachmentThumbnailType.Location,
blurHash = null,
) )
is TimelineItemTextBasedContent, is TimelineItemTextBasedContent,
is TimelineItemRedactedContent, is TimelineItemRedactedContent,

View file

@ -56,6 +56,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction 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.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.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent 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.TimelineItemImageContent
@ -246,8 +247,6 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
info = AttachmentThumbnailInfo( info = AttachmentThumbnailInfo(
type = AttachmentThumbnailType.Location, type = AttachmentThumbnailType.Location,
textContent = stringResource(CommonStrings.common_shared_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( AttachmentThumbnail(
modifier = imageModifier, modifier = imageModifier,
info = AttachmentThumbnailInfo( info = AttachmentThumbnailInfo(
mediaSource = event.content.mediaSource, thumbnailSource = event.content.mediaSource,
textContent = textContent, textContent = textContent,
type = AttachmentThumbnailType.File, type = AttachmentThumbnailType.Image,
blurHash = event.content.blurhash, blurHash = event.content.blurhash,
) )
) )
@ -272,7 +271,7 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
AttachmentThumbnail( AttachmentThumbnail(
modifier = imageModifier, modifier = imageModifier,
info = AttachmentThumbnailInfo( info = AttachmentThumbnailInfo(
mediaSource = event.content.thumbnailSource, thumbnailSource = event.content.thumbnailSource,
textContent = textContent, textContent = textContent,
type = AttachmentThumbnailType.Video, type = AttachmentThumbnailType.Video,
blurHash = event.content.blurHash, blurHash = event.content.blurHash,
@ -286,10 +285,21 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
AttachmentThumbnail( AttachmentThumbnail(
modifier = imageModifier, modifier = imageModifier,
info = AttachmentThumbnailInfo( info = AttachmentThumbnailInfo(
mediaSource = null, thumbnailSource = event.content.thumbnailSource,
textContent = textContent, textContent = textContent,
type = AttachmentThumbnailType.File, 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.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Attachment import androidx.compose.material.icons.outlined.Attachment
import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem 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.exoplayer.ExoPlayerWrapper
import io.element.android.features.messages.impl.media.local.pdf.PdfViewer 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.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
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.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.R import io.element.android.libraries.designsystem.R
@ -103,6 +105,7 @@ fun LocalMediaView(
zoomableState = zoomableState, zoomableState = zoomableState,
modifier = modifier modifier = modifier
) )
//TODO handle audio with exoplayer
else -> MediaFileView( else -> MediaFileView(
localMediaViewState = localMediaViewState, localMediaViewState = localMediaViewState,
uri = localMedia?.uri, uri = localMedia?.uri,
@ -215,6 +218,7 @@ fun MediaFileView(
info: MediaInfo?, info: MediaInfo?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isAudio = info?.mimeType.isMimeTypeAudio().orFalse()
localMediaViewState.isReady = uri != null localMediaViewState.isReady = uri != null
Box(modifier = modifier.padding(horizontal = 8.dp), contentAlignment = Alignment.Center) { Box(modifier = modifier.padding(horizontal = 8.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
@ -226,12 +230,12 @@ fun MediaFileView(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
imageVector = Icons.Outlined.Attachment, imageVector = if (isAudio) Icons.Outlined.GraphicEq else Icons.Outlined.Attachment,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.background, tint = MaterialTheme.colorScheme.background,
modifier = Modifier modifier = Modifier
.size(32.dp) .size(32.dp)
.rotate(-45f), .rotate(if (isAudio) 0f else -45f),
) )
} }
if (info != null) { if (info != null) {

View file

@ -29,7 +29,7 @@ data class MediaInfo(
) : Parcelable ) : Parcelable
fun anImageInfo(): MediaInfo = MediaInfo( fun anImageInfo(): MediaInfo = MediaInfo(
"an image file.jpg", MimeTypes.Jpeg, "4MB","jpg" "an image file.jpg", MimeTypes.Jpeg, "4MB", "jpg"
) )
fun aVideoInfo(): MediaInfo = MediaInfo( fun aVideoInfo(): MediaInfo = MediaInfo(
@ -43,3 +43,7 @@ fun aPdfInfo(): MediaInfo = MediaInfo(
fun aFileInfo(): MediaInfo = MediaInfo( fun aFileInfo(): MediaInfo = MediaInfo(
"an apk file.apk", MimeTypes.Apk, "50MB", "apk" "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.aFileInfo
import io.element.android.features.messages.impl.media.local.aPdfInfo 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.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.features.messages.impl.media.local.anImageInfo
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
@ -59,7 +60,17 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
LocalMedia(Uri.EMPTY, aFileInfo()) LocalMedia(Uri.EMPTY, aFileInfo())
), ),
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.Modifier
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R 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.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider 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.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreviewLight 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.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -308,20 +307,11 @@ private fun JumpToBottomButton(
} }
} }
@Preview @DayNightPreviews
@Composable @Composable
fun TimelineViewLightPreview( fun TimelineViewPreview(
@PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent @PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent
) = ElementPreviewLight { ContentToPreview(content) } ) = ElementPreview {
@Preview
@Composable
fun TimelineViewDarkPreview(
@PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent
) = ElementPreviewDark { ContentToPreview(content) }
@Composable
private fun ContentToPreview(content: TimelineItemEventContent) {
val timelineItems = aTimelineItemList(content) val timelineItems = aTimelineItemList(content)
TimelineView( TimelineView(
state = aTimelineState(timelineItems), 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_RADIUS = 12.dp
private val BUBBLE_INCOMING_OFFSET = 16.dp private val BUBBLE_INCOMING_OFFSET = 16.dp
// Design says: The maximum width of a bubble is still 3/4 of the screen width // 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.75f private const val BUBBLE_WIDTH_RATIO = 0.85f
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @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.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.constraintlayout.compose.ConstrainScope import androidx.constraintlayout.compose.ConstrainScope
import androidx.constraintlayout.compose.ConstraintLayout 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.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId 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.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.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
@ -521,28 +521,29 @@ private fun ReplyToContent(
private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready) = private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready) =
when (val type = inReplyTo.content.type) { when (val type = inReplyTo.content.type) {
is ImageMessageType -> AttachmentThumbnailInfo( is ImageMessageType -> AttachmentThumbnailInfo(
mediaSource = type.info?.thumbnailSource, thumbnailSource = type.info?.thumbnailSource,
textContent = inReplyTo.content.body, textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.Image, type = AttachmentThumbnailType.Image,
blurHash = type.info?.blurhash, blurHash = type.info?.blurhash,
) )
is VideoMessageType -> AttachmentThumbnailInfo( is VideoMessageType -> AttachmentThumbnailInfo(
mediaSource = type.info?.thumbnailSource, thumbnailSource = type.info?.thumbnailSource,
textContent = inReplyTo.content.body, textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.Video, type = AttachmentThumbnailType.Video,
blurHash = type.info?.blurhash, blurHash = type.info?.blurhash,
) )
is FileMessageType -> AttachmentThumbnailInfo( is FileMessageType -> AttachmentThumbnailInfo(
mediaSource = type.info?.thumbnailSource, thumbnailSource = type.info?.thumbnailSource,
textContent = inReplyTo.content.body, textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.File, type = AttachmentThumbnailType.File,
blurHash = null,
) )
is LocationMessageType -> AttachmentThumbnailInfo( is LocationMessageType -> AttachmentThumbnailInfo(
mediaSource = null,
textContent = inReplyTo.content.body, textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.Location, type = AttachmentThumbnailType.Location,
blurHash = null, )
is AudioMessageType -> AttachmentThumbnailInfo(
textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.Audio,
) )
else -> null 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.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier 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.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@ -80,6 +81,11 @@ fun TimelineItemEventContentView(
extraPadding = extraPadding, extraPadding = extraPadding,
modifier = modifier modifier = modifier
) )
is TimelineItemAudioContent -> TimelineItemAudioView(
content = content,
extraPadding = extraPadding,
modifier = modifier
)
is TimelineItemStateContent -> TimelineItemStateView( is TimelineItemStateContent -> TimelineItemStateView(
content = content, content = content,
modifier = modifier modifier = modifier

View file

@ -22,14 +22,13 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.location.api.StaticMapView 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.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider 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.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
@Composable @Composable
@ -57,17 +56,10 @@ fun TimelineItemLocationView(
} }
} }
@Preview @DayNightPreviews
@Composable @Composable
internal fun TimelineItemLocationViewLightPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) = internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) =
ElementPreviewLight { ContentToPreview(content) } 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 package io.element.android.features.messages.impl.timeline.factories.event
import io.element.android.features.location.api.Location 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.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent 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.features.messages.impl.timeline.util.toHtmlDocument
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes 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.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType 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.ImageMessageType
@ -99,6 +101,14 @@ class TimelineItemContentMessageFactory @Inject constructor(
fileExtension = fileExtensionExtractor.extractFromName(messageType.body) 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( is FileMessageType -> TimelineItemFileContent(
body = messageType.body, body = messageType.body,
thumbnailSource = messageType.info?.thumbnailSource, thumbnailSource = messageType.info?.thumbnailSource,

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.timeline.groups 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.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.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent 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.TimelineItemImageContent
@ -52,6 +53,7 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean {
is TimelineItemImageContent, is TimelineItemImageContent,
is TimelineItemFileContent, is TimelineItemFileContent,
is TimelineItemVideoContent, is TimelineItemVideoContent,
is TimelineItemAudioContent,
is TimelineItemLocationContent, is TimelineItemLocationContent,
TimelineItemRedactedContent, TimelineItemRedactedContent,
TimelineItemUnknownContent -> false 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(), aTimelineItemEncryptedContent(),
aTimelineItemImageContent(), aTimelineItemImageContent(),
aTimelineItemVideoContent(), aTimelineItemVideoContent(),
aTimelineItemFileContent("A file.pdf"), aTimelineItemFileContent(),
aTimelineItemFileContent("A bigger file name which doesn't fit.pdf"), aTimelineItemFileContent("A bigger file name which doesn't fit.pdf"),
aTimelineItemLocationContent(), aTimelineItemLocationContent(),
aTimelineItemLocationContent("Location description"), aTimelineItemLocationContent("Location description"),

View file

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

View file

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

View file

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

View file

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

View file

@ -16,7 +16,6 @@
package io.element.android.features.onboarding.impl package io.element.android.features.onboarding.impl
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.QrCode 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.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.BiasAlignment import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign 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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.aliasButtonText 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.Button
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
@ -85,10 +83,6 @@ fun OnBoardingView(
@Composable @Composable
private fun OnBoardingContent(modifier: Modifier = Modifier) { 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( Box(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
) { ) {
@ -99,14 +93,9 @@ private fun OnBoardingContent(modifier: Modifier = Modifier) {
verticalBias = -0.4f verticalBias = -0.4f
) )
) { ) {
// Dark and light icon does not have the same size, add padding to the smaller one ElementLogoAtom(
val imagePadding = if (isLight) 28.dp else 0.dp size = ElementLogoAtomSize.Large,
Image( modifier = Modifier.padding(top = ElementLogoAtomSize.Large.shadowRadius / 2)
modifier = Modifier
.size(278.dp)
.padding(imagePadding),
painter = painterResource(id = iconDrawableRes),
contentDescription = null,
) )
} }
Box( Box(
@ -200,17 +189,10 @@ private fun OnBoardingButtons(
} }
} }
@Preview @DayNightPreviews
@Composable @Composable
internal fun OnBoardingScreenLightPreview(@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState) = internal fun OnBoardingScreenPreview(
ElementPreviewLight { ContentToPreview(state) } @PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState
) = ElementPreview {
@Preview
@Composable
internal fun OnBoardingScreenDarkPreview(@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: OnBoardingState) {
OnBoardingView(state) 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.libraries.uiStrings)
implementation(projects.features.rageshake.api) implementation(projects.features.rageshake.api)
implementation(projects.features.analytics.api) implementation(projects.features.analytics.api)
implementation(projects.features.ftue.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.features.logout.api) implementation(projects.features.logout.api)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)

View file

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

View file

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

View file

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

View file

@ -605,7 +605,7 @@ class RoomDetailsEditPresenterTest {
Result.success( Result.success(
MediaUploadInfo.AnyFile( MediaUploadInfo.AnyFile(
file = processedFile, 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" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
timber = "com.jakewharton.timber:timber:5.0.1" 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-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-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" } 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. * 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. // We cannot use NotificationManagerCompat here.
val setting = context.getSystemService<NotificationManager>()!!.currentInterruptionFilter val setting = getSystemService<NotificationManager>()!!.currentInterruptionFilter
return setting == NotificationManager.INTERRUPTION_FILTER_NONE || return setting == NotificationManager.INTERRUPTION_FILTER_NONE ||
setting == NotificationManager.INTERRUPTION_FILTER_ALARMS 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". * will return false and the notification privacy will fallback to "LOW_DETAIL".
*/ */
@SuppressLint("BatteryLife") @SuppressLint("BatteryLife")
fun requestDisablingBatteryOptimization(activity: Activity, activityResultLauncher: ActivityResultLauncher<Intent>) { fun Context.requestDisablingBatteryOptimization(activityResultLauncher: ActivityResultLauncher<Intent>) {
val intent = Intent() val intent = Intent()
intent.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS intent.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
intent.data = Uri.parse("package:" + activity.packageName) intent.data = Uri.parse("package:$packageName")
activityResultLauncher.launch(intent) activityResultLauncher.launch(intent)
} }
@ -106,50 +106,48 @@ fun requestDisablingBatteryOptimization(activity: Activity, activityResultLaunch
/** /**
* Copy a text to the clipboard, and display a Toast when done. * 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 text the text to copy
* @param toastMessage content of the toast message as a String resource. Null for no toast * @param toastMessage content of the toast message as a String resource. Null for no toast
*/ */
fun copyToClipboard( fun Context.copyToClipboard(
context: Context,
text: CharSequence, text: CharSequence,
toastMessage: String? = null toastMessage: String? = null
) { ) {
CopyToClipboardUseCase(context).execute(text) CopyToClipboardUseCase(this).execute(text)
toastMessage?.let { context.toast(it) } toastMessage?.let { toast(it) }
} }
/** /**
* Shows notification settings for the current app. * 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 * 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() val intent = Intent()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) intent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
} else { } else {
intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 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) activityResultLauncher.launch(intent)
} }
fun openAppSettingsPage( fun Context.openAppSettingsPage(
activity: Activity, noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found),
noActivityFoundMessage: String = activity.getString(R.string.error_no_compatible_app_found),
) { ) {
try { try {
activity.startActivity( startActivity(
Intent().apply { Intent().apply {
action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
data = Uri.fromParts("package", activity.packageName, null) data = Uri.fromParts("package", packageName, null)
} }
) )
} catch (activityNotFoundException: ActivityNotFoundException) { } catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(noActivityFoundMessage) toast(noActivityFoundMessage)
} }
} }
@ -157,52 +155,49 @@ fun openAppSettingsPage(
* Shows notification system settings for the given channel id. * Shows notification system settings for the given channel id.
*/ */
@TargetApi(Build.VERSION_CODES.O) @TargetApi(Build.VERSION_CODES.O)
fun startNotificationChannelSettingsIntent(activity: Activity, channelID: String) { fun Activity.startNotificationChannelSettingsIntent(channelID: String) {
if (!supportNotificationChannels()) return if (!supportNotificationChannels()) return
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { 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) putExtra(Settings.EXTRA_CHANNEL_ID, channelID)
} }
activity.startActivity(intent) startActivity(intent)
} }
fun startAddGoogleAccountIntent( fun Context.startAddGoogleAccountIntent(
context: Context,
activityResultLauncher: ActivityResultLauncher<Intent>, 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) val intent = Intent(Settings.ACTION_ADD_ACCOUNT)
intent.putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf("com.google")) intent.putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf("com.google"))
try { try {
activityResultLauncher.launch(intent) activityResultLauncher.launch(intent)
} catch (activityNotFoundException: ActivityNotFoundException) { } catch (activityNotFoundException: ActivityNotFoundException) {
context.toast(noActivityFoundMessage) toast(noActivityFoundMessage)
} }
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
fun startInstallFromSourceIntent( fun Context.startInstallFromSourceIntent(
context: Context,
activityResultLauncher: ActivityResultLauncher<Intent>, 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) 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 { try {
activityResultLauncher.launch(intent) activityResultLauncher.launch(intent)
} catch (activityNotFoundException: ActivityNotFoundException) { } catch (activityNotFoundException: ActivityNotFoundException) {
context.toast(noActivityFoundMessage) toast(noActivityFoundMessage)
} }
} }
fun startSharePlainTextIntent( fun Context.startSharePlainTextIntent(
context: Context,
activityResultLauncher: ActivityResultLauncher<Intent>?, activityResultLauncher: ActivityResultLauncher<Intent>?,
chooserTitle: String?, chooserTitle: String?,
text: String, text: String,
subject: String? = null, subject: String? = null,
extraTitle: 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) val share = Intent(Intent.ACTION_SEND)
share.type = "text/plain" share.type = "text/plain"
@ -220,17 +215,16 @@ fun startSharePlainTextIntent(
if (activityResultLauncher != null) { if (activityResultLauncher != null) {
activityResultLauncher.launch(intent) activityResultLauncher.launch(intent)
} else { } else {
context.startActivity(intent) startActivity(intent)
} }
} catch (activityNotFoundException: ActivityNotFoundException) { } catch (activityNotFoundException: ActivityNotFoundException) {
context.toast(noActivityFoundMessage) toast(noActivityFoundMessage)
} }
} }
fun startImportTextFromFileIntent( fun Context.startImportTextFromFileIntent(
context: Context,
activityResultLauncher: ActivityResultLauncher<Intent>, 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 { val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
type = "text/plain" type = "text/plain"
@ -238,7 +232,7 @@ fun startImportTextFromFileIntent(
try { try {
activityResultLauncher.launch(intent) activityResultLauncher.launch(intent)
} catch (activityNotFoundException: ActivityNotFoundException) { } catch (activityNotFoundException: ActivityNotFoundException) {
context.toast(noActivityFoundMessage) toast(noActivityFoundMessage)
} }
} }

View file

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

View file

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

View file

@ -53,82 +53,72 @@ import io.element.android.libraries.theme.ElementTheme
fun ElementLogoAtom( fun ElementLogoAtom(
size: ElementLogoAtomSize, size: ElementLogoAtomSize,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
darkTheme: Boolean = isSystemInDarkTheme(),
) { ) {
val outerSize = when (size) { val blur = if (darkTheme) 160.dp else 24.dp
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
}
//box-shadow: 0px 6.075949668884277px 24.30379867553711px 0px #1B1D2280; //box-shadow: 0px 6.075949668884277px 24.30379867553711px 0px #1B1D2280;
val shadowColor = if (isSystemInDarkTheme()) { val shadowColor = if (darkTheme) size.shadowColorDark else size.shadowColorLight
Color.Black.copy(alpha = 0.4f) val backgroundColor = if (darkTheme) Color.White.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.4f)
} else { val borderColor = if (darkTheme) Color.White.copy(alpha = 0.8f) else Color.White.copy(alpha = 0.4f)
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)
Box( Box(
modifier = modifier modifier = modifier
.size(outerSize) .size(size.outerSize)
.border(borderWidth, borderColor, RoundedCornerShape(cornerRadius)), .border(size.borderWidth, borderColor, RoundedCornerShape(size.cornerRadius)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Box( Box(
Modifier Modifier
.size(outerSize) .size(size.outerSize)
.shapeShadow( .shapeShadow(
color = shadowColor, color = shadowColor,
cornerRadius = cornerRadius, cornerRadius = size.cornerRadius,
blurRadius = 32.dp, blurRadius = size.shadowRadius,
offsetY = 8.dp, offsetY = 8.dp,
) )
) )
Box( Box(
Modifier Modifier
.clip(RoundedCornerShape(cornerRadius)) .clip(RoundedCornerShape(size.cornerRadius))
.size(outerSize) .size(size.outerSize)
.background(backgroundColor) .background(backgroundColor)
.blur(blur) .blur(blur)
) )
Image( Image(
modifier = Modifier.size(logoSize), modifier = Modifier.size(size.logoSize),
painter = painterResource(id = R.drawable.element_logo), painter = painterResource(id = R.drawable.element_logo),
contentDescription = null contentDescription = null
) )
} }
} }
enum class ElementLogoAtomSize { sealed class ElementLogoAtomSize(
Medium, val outerSize: Dp,
Large 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 object Large : ElementLogoAtomSize(
@DayNightPreviews outerSize = 158.dp,
internal fun ElementLogoAtomPreview() { logoSize = 110.dp,
ElementPreview { cornerRadius = 44.dp,
Box( borderWidth = 0.5.dp,
Modifier shadowColorDark = Color.Black,
.size(170.dp) shadowColorLight = Color(0x801B1D22),
.background(ElementTheme.colors.bgSubtlePrimary)) shadowRadius = 60.dp,
ElementLogoAtom(ElementLogoAtomSize.Large) )
}
} }
fun Modifier.shapeShadow( 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.atomic.atoms.InfoListItemPosition
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@Composable @Composable
@ -54,7 +55,13 @@ fun InfoListOrganism(
else -> InfoListItemPosition.Middle else -> InfoListItemPosition.Middle
} }
InfoListItemMolecule( InfoListItemMolecule(
message = { Text(item.message, style = textStyle) }, message = {
Text(
text = item.message,
style = textStyle,
color = ElementTheme.colors.textPrimary,
)
},
icon = { icon = {
if (item.iconId != null) { if (item.iconId != null) {
Icon(resourceId = item.iconId, contentDescription = null, tint = iconTint) 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.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.R import io.element.android.libraries.designsystem.R
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
@ -52,10 +51,6 @@ fun OnBoardingPage(
footer: @Composable () -> Unit = {}, footer: @Composable () -> Unit = {},
content: @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( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@ -64,7 +59,7 @@ fun OnBoardingPage(
Image( Image(
modifier = Modifier modifier = Modifier
.fillMaxSize(), .fillMaxSize(),
painter = painterResource(id = bgDrawableRes), painter = painterResource(id = R.drawable.onboarding_bg),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
contentDescription = null, contentDescription = null,
) )
@ -92,18 +87,9 @@ fun OnBoardingPage(
} }
} }
@Preview @DayNightPreviews
@Composable @Composable
internal fun OnBoardingPageLightPreview() = internal fun OnBoardingPagePreview() = ElementPreview {
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun OnBoardingPageDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
OnBoardingPage( OnBoardingPage(
content = { content = {
Box( Box(

View file

@ -20,14 +20,17 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog 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.preview.PreviewGroup
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator 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.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.CommonStrings
import timber.log.Timber
@Composable @Composable
fun ProgressDialog( fun ProgressDialog(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
text: String? = null, text: String? = null,
type: ProgressDialogType = ProgressDialogType.Indeterminate, type: ProgressDialogType = ProgressDialogType.Indeterminate,
onDismiss: () -> Unit = {}, isCancellable: Boolean = false,
onDismissRequest: () -> Unit = {},
) { ) {
DisposableEffect(Unit) {
onDispose {
Timber.v("OnDispose progressDialog")
}
}
Dialog( Dialog(
onDismissRequest = onDismiss, onDismissRequest = onDismissRequest,
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
) { ) {
ProgressDialogContent( ProgressDialogContent(
modifier = modifier, modifier = modifier,
text = text, text = text,
isCancellable = isCancellable,
onCancelClicked = onDismissRequest,
progressIndicator = { progressIndicator = {
when (type) { when (type) {
is ProgressDialogType.Indeterminate -> { is ProgressDialogType.Indeterminate -> {
@ -81,6 +95,8 @@ sealed interface ProgressDialogType {
private fun ProgressDialogContent( private fun ProgressDialogContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
text: String? = null, text: String? = null,
isCancellable: Boolean = false,
onCancelClicked: () -> Unit = {},
progressIndicator: @Composable () -> Unit = { progressIndicator: @Composable () -> Unit = {
CircularProgressIndicator( CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
@ -107,6 +123,17 @@ private fun ProgressDialogContent(
color = MaterialTheme.colorScheme.primary, 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 @Composable
private fun ContentToPreview() { private fun ContentToPreview() {
DialogPreview { 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( data class AudioInfo(
val duration: Duration?, val duration: Duration?,
val size: Long?, 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 leave(): Result<Unit>
suspend fun acceptInvitation(): Result<Unit> suspend fun join(): Result<Unit>
suspend fun rejectInvitation(): Result<Unit>
suspend fun inviteUserById(id: UserId): 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( fun RustAudioInfo.map(): AudioInfo = AudioInfo(
duration = duration, duration = duration,
size = size?.toLong(), size = size?.toLong(),
mimeType = mimetype mimetype = mimetype
) )
fun AudioInfo.map(): RustAudioInfo = RustAudioInfo( fun AudioInfo.map(): RustAudioInfo = RustAudioInfo(
duration = duration, duration = duration,
size = size?.toULong(), 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 { runCatching {
innerRoom.acceptInvitation() innerRoom.join()
}
}
override suspend fun rejectInvitation(): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.rejectInvitation()
} }
} }

View file

@ -63,8 +63,7 @@ class FakeMatrixRoom(
private var userDisplayNameResult = Result.success<String?>(null) private var userDisplayNameResult = Result.success<String?>(null)
private var userAvatarUrlResult = Result.success<String?>(null) private var userAvatarUrlResult = Result.success<String?>(null)
private var updateMembersResult: Result<Unit> = Result.success(Unit) private var updateMembersResult: Result<Unit> = Result.success(Unit)
private var acceptInviteResult = Result.success(Unit) private var joinRoomResult = Result.success(Unit)
private var rejectInviteResult = Result.success(Unit)
private var inviteUserResult = Result.success(Unit) private var inviteUserResult = Result.success(Unit)
private var canInviteResult = Result.success(true) private var canInviteResult = Result.success(true)
private val canSendStateResults = mutableMapOf<StateEventType, Result<Boolean>>() private val canSendStateResults = mutableMapOf<StateEventType, Result<Boolean>>()
@ -101,11 +100,6 @@ class FakeMatrixRoom(
var sendLocationCount: Int = 0 var sendLocationCount: Int = 0
private set private set
var isInviteAccepted: Boolean = false
private set
var isInviteRejected: Boolean = false
private set
var invitedUserId: UserId? = null var invitedUserId: UserId? = null
private set private set
@ -196,16 +190,11 @@ class FakeMatrixRoom(
return Result.success(Unit) 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> { override suspend fun join(): Result<Unit> {
isInviteAccepted = true return joinRoomResult
return acceptInviteResult
}
override suspend fun rejectInvitation(): Result<Unit> {
isInviteRejected = true
return rejectInviteResult
} }
override suspend fun inviteUserById(id: UserId): Result<Unit> = simulateLongTask { override suspend fun inviteUserById(id: UserId): Result<Unit> = simulateLongTask {
@ -316,12 +305,8 @@ class FakeMatrixRoom(
userAvatarUrlResult = avatarUrl userAvatarUrlResult = avatarUrl
} }
fun givenAcceptInviteResult(result: Result<Unit>) { fun givenJoinRoomResult(result: Result<Unit>) {
acceptInviteResult = result joinRoomResult = result
}
fun givenRejectInviteResult(result: Result<Unit>) {
rejectInviteResult = result
} }
fun givenInviteUserResult(result: Result<Unit>) { 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.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Attachment import androidx.compose.material.icons.outlined.Attachment
import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.material.icons.outlined.VideoCameraBack import androidx.compose.material.icons.outlined.VideoCameraBack
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -44,9 +45,9 @@ fun AttachmentThumbnail(
thumbnailSize: Long = 32L, thumbnailSize: Long = 32L,
backgroundColor: Color = MaterialTheme.colorScheme.surface, backgroundColor: Color = MaterialTheme.colorScheme.surface,
) { ) {
if (info.mediaSource != null) { if (info.thumbnailSource != null) {
val mediaRequestData = MediaRequestData( val mediaRequestData = MediaRequestData(
source = info.mediaSource, source = info.thumbnailSource,
kind = MediaRequestData.Kind.Thumbnail(thumbnailSize), kind = MediaRequestData.Kind.Thumbnail(thumbnailSize),
) )
BlurHashAsyncImage( BlurHashAsyncImage(
@ -68,6 +69,12 @@ fun AttachmentThumbnail(
contentDescription = info.textContent, contentDescription = info.textContent,
) )
} }
AttachmentThumbnailType.Audio -> {
Icon(
imageVector = Icons.Outlined.GraphicEq,
contentDescription = info.textContent,
)
}
AttachmentThumbnailType.File -> { AttachmentThumbnailType.File -> {
Icon( Icon(
imageVector = Icons.Outlined.Attachment, imageVector = Icons.Outlined.Attachment,
@ -88,13 +95,13 @@ fun AttachmentThumbnail(
@Parcelize @Parcelize
enum class AttachmentThumbnailType: Parcelable { enum class AttachmentThumbnailType: Parcelable {
Image, Video, File, Location Image, Video, File, Audio, Location
} }
@Parcelize @Parcelize
data class AttachmentThumbnailInfo( data class AttachmentThumbnailInfo(
val mediaSource: MediaSource?, val type: AttachmentThumbnailType,
val textContent: String?, val thumbnailSource: MediaSource? = null,
val type: AttachmentThumbnailType?, val textContent: String? = null,
val blurHash: String?, val blurHash: String? = null,
): Parcelable ): Parcelable

View file

@ -46,36 +46,43 @@ class MediaSender @Inject constructor(
} }
private suspend fun MatrixRoom.sendMedia( private suspend fun MatrixRoom.sendMedia(
info: MediaUploadInfo, uploadInfo: MediaUploadInfo,
progressCallback: ProgressCallback? progressCallback: ProgressCallback?
): Result<Unit> { ): Result<Unit> {
return when (info) { return when (uploadInfo) {
is MediaUploadInfo.Image -> { is MediaUploadInfo.Image -> {
sendImage( sendImage(
file = info.file, file = uploadInfo.file,
thumbnailFile = info.thumbnailFile, thumbnailFile = uploadInfo.thumbnailFile,
imageInfo = info.info, imageInfo = uploadInfo.imageInfo,
progressCallback = progressCallback progressCallback = progressCallback
) )
} }
is MediaUploadInfo.Video -> { is MediaUploadInfo.Video -> {
sendVideo( sendVideo(
file = info.file, file = uploadInfo.file,
thumbnailFile = info.thumbnailFile, thumbnailFile = uploadInfo.thumbnailFile,
videoInfo = info.info, videoInfo = uploadInfo.videoInfo,
progressCallback = progressCallback
)
}
is MediaUploadInfo.Audio -> {
sendAudio(
file = uploadInfo.file,
audioInfo = uploadInfo.audioInfo,
progressCallback = progressCallback progressCallback = progressCallback
) )
} }
is MediaUploadInfo.AnyFile -> { is MediaUploadInfo.AnyFile -> {
sendFile( sendFile(
file = info.file, file = uploadInfo.file,
fileInfo = info.info, fileInfo = uploadInfo.fileInfo,
progressCallback = progressCallback 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 val file: File
data class Image(override val file: File, val info: ImageInfo, val thumbnailFile: File) : MediaUploadInfo data class Image(override val file: File, val imageInfo: ImageInfo, val thumbnailFile: File) : MediaUploadInfo
data class Video(override val file: File, val info: VideoInfo, 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 info: AudioInfo) : MediaUploadInfo data class Audio(override val file: File, val audioInfo: AudioInfo) : MediaUploadInfo
data class AnyFile(override val file: File, val info: FileInfo) : 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) removeSensitiveImageMetadata(compressionResult.file)
return MediaUploadInfo.Image( return MediaUploadInfo.Image(
file = compressionResult.file, file = compressionResult.file,
info = imageInfo, imageInfo = imageInfo,
thumbnailFile = thumbnailResult.file thumbnailFile = thumbnailResult.file
) )
} }
@ -156,7 +156,7 @@ class AndroidMediaPreProcessor @Inject constructor(
removeSensitiveImageMetadata(file) removeSensitiveImageMetadata(file)
return MediaUploadInfo.Image( return MediaUploadInfo.Image(
file = file, file = file,
info = imageInfo, imageInfo = imageInfo,
thumbnailFile = thumbnailResult.file thumbnailFile = thumbnailResult.file
) )
} }
@ -184,7 +184,7 @@ class AndroidMediaPreProcessor @Inject constructor(
val videoInfo = extractVideoMetadata(resultFile, mimeType, thumbnailInfo) val videoInfo = extractVideoMetadata(resultFile, mimeType, thumbnailInfo)
return MediaUploadInfo.Video( return MediaUploadInfo.Video(
file = resultFile, file = resultFile,
info = videoInfo, videoInfo = videoInfo,
thumbnailFile = thumbnailInfo.file thumbnailFile = thumbnailInfo.file
) )
} }
@ -196,7 +196,7 @@ class AndroidMediaPreProcessor @Inject constructor(
val info = AudioInfo( val info = AudioInfo(
duration = extractDuration(), duration = extractDuration(),
size = file.length(), size = file.length(),
mimeType = mimeType, mimetype = mimeType,
) )
MediaUploadInfo.Audio(file, info) 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) private fun supportNotificationChannels() = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
fun openSystemSettingsForSilentCategory(activity: Activity) { fun openSystemSettingsForSilentCategory(activity: Activity) {
startNotificationChannelSettingsIntent(activity, SILENT_NOTIFICATION_CHANNEL_ID) activity.startNotificationChannelSettingsIntent(SILENT_NOTIFICATION_CHANNEL_ID)
} }
fun openSystemSettingsForNoisyCategory(activity: Activity) { fun openSystemSettingsForNoisyCategory(activity: Activity) {
startNotificationChannelSettingsIntent(activity, NOISY_NOTIFICATION_CHANNEL_ID) activity.startNotificationChannelSettingsIntent(NOISY_NOTIFICATION_CHANNEL_ID)
} }
fun openSystemSettingsForCallCategory(activity: Activity) { 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", senderName = "Alice",
eventId = EventId("$1234"), eventId = EventId("$1234"),
attachmentThumbnailInfo = AttachmentThumbnailInfo( attachmentThumbnailInfo = AttachmentThumbnailInfo(
mediaSource = MediaSource("https://domain.com/image.jpg"), thumbnailSource = MediaSource("https://domain.com/image.jpg"),
textContent = "image.jpg", textContent = "image.jpg",
type = AttachmentThumbnailType.Image, type = AttachmentThumbnailType.Image,
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr", blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
@ -501,7 +501,7 @@ fun TextComposerReplyPreview() = ElementPreview {
senderName = "Alice", senderName = "Alice",
eventId = EventId("$1234"), eventId = EventId("$1234"),
attachmentThumbnailInfo = AttachmentThumbnailInfo( attachmentThumbnailInfo = AttachmentThumbnailInfo(
mediaSource = MediaSource("https://domain.com/video.mp4"), thumbnailSource = MediaSource("https://domain.com/video.mp4"),
textContent = "video.mp4", textContent = "video.mp4",
type = AttachmentThumbnailType.Video, type = AttachmentThumbnailType.Video,
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr", blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
@ -519,7 +519,7 @@ fun TextComposerReplyPreview() = ElementPreview {
senderName = "Alice", senderName = "Alice",
eventId = EventId("$1234"), eventId = EventId("$1234"),
attachmentThumbnailInfo = AttachmentThumbnailInfo( attachmentThumbnailInfo = AttachmentThumbnailInfo(
mediaSource = null, thumbnailSource = null,
textContent = "logs.txt", textContent = "logs.txt",
type = AttachmentThumbnailType.File, type = AttachmentThumbnailType.File,
blurHash = null, blurHash = null,
@ -537,7 +537,7 @@ fun TextComposerReplyPreview() = ElementPreview {
senderName = "Alice", senderName = "Alice",
eventId = EventId("$1234"), eventId = EventId("$1234"),
attachmentThumbnailInfo = AttachmentThumbnailInfo( attachmentThumbnailInfo = AttachmentThumbnailInfo(
mediaSource = null, thumbnailSource = null,
textContent = null, textContent = null,
type = AttachmentThumbnailType.Location, type = AttachmentThumbnailType.Location,
blurHash = null, blurHash = null,

View file

@ -58,4 +58,9 @@ interface AnalyticsService: AnalyticsTracker, ErrorTracker {
* To be called when a session is destroyed. * To be called when a session is destroyed.
*/ */
suspend fun onSignOut() 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() analyticsStore.setDidAskUserConsent()
} }
override suspend fun reset() {
analyticsStore.setDidAskUserConsent(false)
}
override fun getAnalyticsId(): Flow<String> { override fun getAnalyticsId(): Flow<String> {
return analyticsStore.analyticsIdFlow return analyticsStore.analyticsIdFlow
} }

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:64a8da63cd27373a61e788cdf569e05f1608ab32bebffd2651755d5a3345c37c oid sha256:66a7d55b40a9d5d7bc02f531bd3c80a1ba96e24a619c123f68549df355019558
size 45752 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