Merge remote-tracking branch 'origin/develop' into feature/cjs/view-location-in-timeline

This commit is contained in:
Chris Smith 2023-06-30 09:30:31 +01:00
commit 005b22391f
454 changed files with 2400 additions and 1234 deletions

View file

@ -16,8 +16,6 @@
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
@ -25,29 +23,14 @@ android {
namespace = "io.element.android.features.location.api"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(libs.dagger)
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.network)
implementation(projects.libraries.core)
implementation(projects.libraries.uiStrings)
implementation(libs.maplibre)
implementation(libs.network.retrofit)
implementation(libs.maplibre.annotation)
implementation(libs.coil.compose)
implementation(libs.serialization.json)
implementation(libs.accompanist.permission)
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.truth)
testImplementation(projects.libraries.matrix.test)
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.api
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
/**
* The "Send location" screen.
*
* Allows a user to share a location message within a room.
*/
interface SendLocationEntryPoint : SimpleFeatureEntryPoint

View file

@ -27,7 +27,7 @@ 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
internal fun buildTileServerUrl(
fun buildTileServerUrl(
darkMode: Boolean
): String = if (!darkMode) {
"$BASE_URL/maps/$LIGHT_MAP_ID/style.json?key=$API_KEY"

View file

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

View file

@ -17,7 +17,6 @@
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
@ -30,18 +29,18 @@ anvil {
}
dependencies {
implementation(libs.dagger)
api(projects.features.location.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.di)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.network)
implementation(projects.libraries.core)
implementation(libs.maplibre)
implementation(libs.network.retrofit)
implementation(libs.maplibre.annotation)
implementation(libs.coil.compose)
implementation(libs.serialization.json)
implementation(libs.accompanist.permission)
implementation(projects.libraries.uiStrings)
implementation(libs.dagger)
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.location.api.SendLocationEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class SendLocationEntryPointImpl @Inject constructor() : SendLocationEntryPoint {
override fun createNode(
parentNode: Node, buildContext: BuildContext
): SendLocationNode = parentNode.createNode(buildContext)
}

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl
sealed interface SendLocationEvents {
data class ShareLocation(val lat: Double, val lng: Double) : SendLocationEvents
data class SwitchMode(val mode: SendLocationState.Mode) : SendLocationEvents
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
@ContributesNode(RoomScope::class)
class SendLocationNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: SendLocationPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
SendLocationView(
state = presenter.present(),
modifier = modifier,
onBackPressed = ::navigateUp,
)
}
}

View file

@ -0,0 +1,67 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import kotlinx.coroutines.launch
import javax.inject.Inject
class SendLocationPresenter @Inject constructor(
private val room: MatrixRoom,
) : Presenter<SendLocationState> {
@Composable
override fun present(): SendLocationState {
val scope = rememberCoroutineScope()
var mode by remember {
mutableStateOf<SendLocationState.Mode>(SendLocationState.Mode.ALocation)
}
fun handleEvents(event: SendLocationEvents) {
when (event) {
is SendLocationEvents.ShareLocation -> scope.launch {
shareLocation(event)
}
is SendLocationEvents.SwitchMode -> {
mode = event.mode
}
}
}
return SendLocationState(
mode = mode,
eventSink = ::handleEvents,
)
}
private suspend fun shareLocation(
event: SendLocationEvents.ShareLocation
) {
room.sendLocation(
body = "Location at latitude: ${event.lat}, longitude: ${event.lng}",
geoUri = "geo:${event.lat},${event.lng}",
)
}
}

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl
data class SendLocationState(
val mode: Mode = Mode.ALocation,
val eventSink: (SendLocationEvents) -> Unit = {},
) {
sealed interface Mode {
object MyLocation : Mode
object ALocation : Mode
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
class SendLocationStateProvider : PreviewParameterProvider<SendLocationState> {
override val values: Sequence<SendLocationState>
get() = sequenceOf(
SendLocationState(),
)
}

View file

@ -0,0 +1,142 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.location.api.R
import io.element.android.features.location.impl.map.MapView
import io.element.android.features.location.impl.map.rememberMapState
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun SendLocationView(
state: SendLocationState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
val mapState = rememberMapState()
BottomSheetScaffold(
sheetContent = {
Spacer(modifier = Modifier.height(16.dp))
ListItem(
headlineContent = {
Text(stringResource(CommonStrings.screen_share_this_location_action))
},
modifier = Modifier.clickable {
state.eventSink(
SendLocationEvents.ShareLocation(
lat = mapState.position.lat,
lng = mapState.position.lon
)
)
onBackPressed()
},
leadingContent = {
Icon(Icons.Default.LocationOn, null)
},
)
Spacer(modifier = Modifier.height(16.dp))
},
modifier = modifier,
scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded),
),
sheetDragHandle = {},
sheetSwipeEnabled = false,
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(CommonStrings.screen_share_location_title),
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
},
navigationIcon = {
BackButton(onClick = onBackPressed)
},
)
},
) {
Box(
modifier = Modifier
.padding(it)
.consumeWindowInsets(it),
contentAlignment = Alignment.Center
) {
MapView(
modifier = Modifier.fillMaxSize(),
mapState = mapState,
)
Icon(
resourceId = R.drawable.pin,
contentDescription = null,
tint = Color.Unspecified
)
}
}
}
@Preview
@Composable
internal fun SendLocationViewLightPreview(@PreviewParameter(SendLocationStateProvider::class) state: SendLocationState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun SendLocationViewDarkPreview(@PreviewParameter(SendLocationStateProvider::class) state: SendLocationState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: SendLocationState) {
SendLocationView(
state = state,
onBackPressed = {},
)
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.location.api
package io.element.android.features.location.impl.location
/**
* Represents a location sample emitted by the device's location subsystem.

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.location.impl
package io.element.android.features.location.impl.location
import android.Manifest
import android.content.Context
@ -25,7 +25,6 @@ import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import androidx.core.location.LocationRequestCompat
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.features.location.api.Location
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking

View file

@ -14,15 +14,13 @@
* limitations under the License.
*/
package io.element.android.features.location.api
package io.element.android.features.location.impl.map
import android.annotation.SuppressLint
import android.view.Gravity
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@ -31,7 +29,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
@ -47,11 +44,11 @@ import com.mapbox.mapboxsdk.maps.MapboxMap
import com.mapbox.mapboxsdk.maps.Style
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
import io.element.android.features.location.api.R
import io.element.android.features.location.api.internal.buildTileServerUrl
import io.element.android.features.location.impl.location.Location
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@ -68,7 +65,6 @@ fun MapView(
modifier: Modifier = Modifier,
mapState: MapState = rememberMapState(),
darkMode: Boolean = !ElementTheme.isLightTheme,
onLocationClick: () -> Unit,
) {
// When in preview, early return a Box with the received modifier preserving layout
if (LocalInspectionMode.current) {
@ -88,7 +84,10 @@ fun MapView(
LaunchedEffect(darkMode) {
mapView.awaitMap().let { map ->
map.uiSettings.apply {
attributionGravity = Gravity.TOP
logoGravity = Gravity.TOP
isCompassEnabled = false
isRotateGesturesEnabled = false
}
map.setStyle(buildTileServerUrl(darkMode = darkMode)) { style ->
mapRefs = MapRefs(
@ -180,20 +179,10 @@ fun MapView(
}
@Suppress("ModifierReused")
Box(modifier = modifier) {
AndroidView(factory = { mapView })
FloatingActionButton(
onClick = onLocationClick,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
) {
Icon(
imageVector = Icons.Filled.LocationOn,
contentDescription = null, // TODO
)
}
}
AndroidView(
factory = { mapView },
modifier = modifier
)
}
@Composable
@ -292,6 +281,5 @@ private fun ContentToPreview() {
)
).toImmutableList()
),
onLocationClick = {},
)
}

View file

@ -0,0 +1,64 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Test
class SendLocationPresenterTest {
private val room = FakeMatrixRoom()
private val presenter = SendLocationPresenter(room)
@Test
fun `emits initial state`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
Truth.assertThat(awaitItem().mode).isEqualTo(SendLocationState.Mode.ALocation)
}
}
@Test
fun `share location event shares a location`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(SendLocationEvents.ShareLocation(1.0, 2.0))
delay(1)
Truth.assertThat(room.sendLocationCount).isEqualTo(1)
}
}
@Test
fun `switches mode`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(SendLocationEvents.SwitchMode(SendLocationState.Mode.MyLocation))
Truth.assertThat(awaitItem().mode).isEqualTo(SendLocationState.Mode.MyLocation)
}
}
}

View file

@ -14,21 +14,20 @@
* limitations under the License.
*/
package io.element.android.features.location.fake
package io.element.android.features.location.impl.location
import io.element.android.features.location.api.Location
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
fun fakeLocationUpdatesFlow(): Flow<Location> = flow {
fun fakeLocationUpdatesFlow(): Flow<io.element.android.features.location.impl.location.Location> = flow {
while (true) {
delay(1_000)
emit(aLocation())
}
}
private fun aLocation() = Location(
private fun aLocation() = io.element.android.features.location.impl.location.Location(
lat = 51.49404,
lon = -0.25484,
accuracy = 5f