Merge pull request #5084 from vector-im/feature/bma/location_crash

Fix location crash
This commit is contained in:
Benoit Marty 2022-01-31 14:42:53 +01:00 committed by GitHub
commit 91e444ca73
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 573 additions and 317 deletions

1
changelog.d/5084.bugfix Normal file
View file

@ -0,0 +1 @@
Display static map images in the timeline and improve Location sharing feature

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="is_rtl">true</bool>
</resources>

View file

@ -4,4 +4,6 @@
<!-- Created to detect what has to be implemented (especially in the settings) -->
<bool name="false_not_implemented">false</bool>
<bool name="is_rtl">false</bool>
</resources>

View file

@ -63,5 +63,5 @@ data class MessageLocationContent(
@Json(name = "org.matrix.msc1767.text") val text: String? = null
) : MessageContent {
fun getUri() = locationInfo?.geoUri ?: geoUri
fun getBestGeoUri() = locationInfo?.geoUri ?: geoUri
}

View file

@ -17,24 +17,25 @@
package im.vector.app.core.epoxy.bottomsheet
import android.text.method.MovementMethod
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import com.bumptech.glide.request.RequestOptions
import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener
import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.epoxy.onClick
import im.vector.app.core.extensions.setTextOrHide
import im.vector.app.core.glide.GlideApp
import im.vector.app.features.displayname.getBestName
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import im.vector.app.features.home.room.detail.timeline.item.BindingOptions
import im.vector.app.features.home.room.detail.timeline.tools.findPillsAndProcess
import im.vector.app.features.location.LocationData
import im.vector.app.features.location.MapTilerMapView
import im.vector.app.features.media.ImageContentRenderer
import im.vector.lib.core.utils.epoxy.charsequence.EpoxyCharSequence
import org.matrix.android.sdk.api.util.MatrixItem
@ -70,7 +71,7 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel<BottomSheetMessa
var time: String? = null
@EpoxyAttribute
var locationData: LocationData? = null
var locationUrl: String? = null
@EpoxyAttribute
var locationPinProvider: LocationPinProvider? = null
@ -97,17 +98,21 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel<BottomSheetMessa
body.charSequence.findPillsAndProcess(coroutineScope) { it.bind(holder.body) }
holder.timestamp.setTextOrHide(time)
holder.mapView.isVisible = locationData != null
holder.body.isVisible = locationData == null
locationData?.let { location ->
holder.mapView.initialize {
if (holder.view.isAttachedToWindow) {
holder.mapView.zoomToLocation(location.latitude, location.longitude, 15.0)
locationPinProvider?.create(matrixItem.id) { pinDrawable ->
holder.mapView.addPinToMap(matrixItem.id, pinDrawable)
holder.mapView.updatePinLocation(matrixItem.id, location.latitude, location.longitude)
}
}
if (locationUrl == null) {
holder.body.isVisible = true
holder.mapViewContainer.isVisible = false
} else {
holder.body.isVisible = false
holder.mapViewContainer.isVisible = true
GlideApp.with(holder.staticMapImageView)
.load(locationUrl)
.apply(RequestOptions.centerCropTransform())
.into(holder.staticMapImageView)
locationPinProvider?.create(matrixItem.id) { pinDrawable ->
GlideApp.with(holder.staticMapPinImageView)
.load(pinDrawable)
.into(holder.staticMapPinImageView)
}
}
}
@ -124,6 +129,8 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel<BottomSheetMessa
val bodyDetails by bind<TextView>(R.id.bottom_sheet_message_preview_body_details)
val timestamp by bind<TextView>(R.id.bottom_sheet_message_preview_timestamp)
val imagePreview by bind<ImageView>(R.id.bottom_sheet_message_preview_image)
val mapView by bind<MapTilerMapView>(R.id.bottom_sheet_message_preview_location)
val mapViewContainer by bind<FrameLayout>(R.id.mapViewContainer)
val staticMapImageView by bind<ImageView>(R.id.staticMapImageView)
val staticMapPinImageView by bind<ImageView>(R.id.staticMapPinImageView)
}
}

View file

@ -20,7 +20,6 @@ import android.net.Uri
import android.view.View
import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.features.call.conference.ConferenceEvent
import im.vector.app.features.location.LocationData
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent
@ -90,6 +89,7 @@ sealed class RoomDetailAction : VectorViewModelAction {
data class EnsureNativeWidgetAllowed(val widget: Widget,
val userJustAccepted: Boolean,
val grantedEvents: RoomDetailViewEvents) : RoomDetailAction()
data class UpdateJoinJitsiCallStatus(val conferenceEvent: ConferenceEvent) : RoomDetailAction()
data class OpenOrCreateDm(val userId: String) : RoomDetailAction()
@ -112,7 +112,4 @@ sealed class RoomDetailAction : VectorViewModelAction {
// Poll
data class EndPoll(val eventId: String) : RoomDetailAction()
// Location
data class ShowLocation(val locationData: LocationData, val userId: String) : RoomDetailAction()
}

View file

@ -171,8 +171,8 @@ import im.vector.app.features.html.EventHtmlRenderer
import im.vector.app.features.html.PillImageSpan
import im.vector.app.features.html.PillsPostProcessor
import im.vector.app.features.invite.VectorInviteView
import im.vector.app.features.location.LocationData
import im.vector.app.features.location.LocationSharingMode
import im.vector.app.features.location.toLocationData
import im.vector.app.features.media.ImageContentRenderer
import im.vector.app.features.media.VideoContentRenderer
import im.vector.app.features.notifications.NotificationDrawerManager
@ -481,7 +481,6 @@ class RoomDetailFragment @Inject constructor(
RoomDetailViewEvents.StopChatEffects -> handleStopChatEffects()
is RoomDetailViewEvents.DisplayAndAcceptCall -> acceptIncomingCall(it)
RoomDetailViewEvents.RoomReplacementStarted -> handleRoomReplacement()
is RoomDetailViewEvents.ShowLocation -> handleShowLocationPreview(it)
}.exhaustive
}
@ -613,14 +612,14 @@ class RoomDetailFragment @Inject constructor(
}
}
private fun handleShowLocationPreview(viewEvent: RoomDetailViewEvents.ShowLocation) {
private fun handleShowLocationPreview(locationContent: MessageLocationContent, senderId: String) {
navigator
.openLocationSharing(
context = requireContext(),
roomId = roomDetailArgs.roomId,
mode = LocationSharingMode.PREVIEW,
initialLocationData = viewEvent.locationData,
locationOwnerId = viewEvent.userId
initialLocationData = locationContent.toLocationData(),
locationOwnerId = senderId
)
}
@ -1828,6 +1827,12 @@ class RoomDetailFragment @Inject constructor(
is EncryptedEventContent -> {
roomDetailViewModel.handle(RoomDetailAction.TapOnFailedToDecrypt(informationData.eventId))
}
is MessageLocationContent -> {
handleShowLocationPreview(messageContent, informationData.senderId)
}
else -> {
Timber.d("No click action defined for this message content")
}
}
}
@ -1940,7 +1945,7 @@ class RoomDetailFragment @Inject constructor(
when (action.messageContent) {
is MessageTextContent -> shareText(requireContext(), action.messageContent.body)
is MessageLocationContent -> {
LocationData.create(action.messageContent.getUri())?.let {
action.messageContent.toLocationData()?.let {
openLocation(requireActivity(), it.latitude, it.longitude)
}
}

View file

@ -20,7 +20,6 @@ import android.net.Uri
import android.view.View
import im.vector.app.core.platform.VectorViewEvents
import im.vector.app.features.call.webrtc.WebRtcCall
import im.vector.app.features.location.LocationData
import org.matrix.android.sdk.api.session.widgets.model.Widget
import org.matrix.android.sdk.api.util.MatrixItem
import org.matrix.android.sdk.internal.crypto.model.event.WithHeldCode
@ -83,6 +82,4 @@ sealed class RoomDetailViewEvents : VectorViewEvents {
data class StartChatEffect(val type: ChatEffect) : RoomDetailViewEvents()
object StopChatEffects : RoomDetailViewEvents()
object RoomReplacementStarted : RoomDetailViewEvents()
data class ShowLocation(val locationData: LocationData, val userId: String) : RoomDetailViewEvents()
}

View file

@ -53,7 +53,6 @@ import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandle
import im.vector.app.features.home.room.detail.timeline.factory.TimelineFactory
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
import im.vector.app.features.home.room.typing.TypingHelper
import im.vector.app.features.location.LocationData
import im.vector.app.features.powerlevel.PowerLevelsFlowFactory
import im.vector.app.features.session.coroutineScope
import im.vector.app.features.settings.VectorDataStore
@ -385,14 +384,9 @@ class RoomDetailViewModel @AssistedInject constructor(
_viewEvents.post(RoomDetailViewEvents.OpenRoom(action.replacementRoomId, closeCurrentRoom = true))
}
is RoomDetailAction.EndPoll -> handleEndPoll(action.eventId)
is RoomDetailAction.ShowLocation -> handleShowLocation(action.locationData, action.userId)
}.exhaustive
}
private fun handleShowLocation(locationData: LocationData, userId: String) {
_viewEvents.post(RoomDetailViewEvents.ShowLocation(locationData, userId))
}
private fun handleJitsiCallJoinStatus(action: RoomDetailAction.UpdateJoinJitsiCallStatus) = withState { state ->
if (state.jitsiState.confId == null) {
// If jitsi widget is removed while on the call

View file

@ -39,7 +39,9 @@ import im.vector.app.features.home.room.detail.timeline.item.E2EDecoration
import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovementMethod
import im.vector.app.features.home.room.detail.timeline.tools.linkify
import im.vector.app.features.html.SpanUtils
import im.vector.app.features.location.LocationData
import im.vector.app.features.location.INITIAL_MAP_ZOOM_IN_TIMELINE
import im.vector.app.features.location.UrlMapProvider
import im.vector.app.features.location.toLocationData
import im.vector.app.features.media.ImageContentRenderer
import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
import org.matrix.android.sdk.api.extensions.orFalse
@ -62,6 +64,7 @@ class MessageActionsEpoxyController @Inject constructor(
private val spanUtils: SpanUtils,
private val eventDetailsFormatter: EventDetailsFormatter,
private val dateFormatter: VectorDateFormatter,
private val urlMapProvider: UrlMapProvider,
private val locationPinProvider: LocationPinProvider
) : TypedEpoxyController<MessageActionState>() {
@ -74,9 +77,11 @@ class MessageActionsEpoxyController @Inject constructor(
val formattedDate = dateFormatter.format(date, DateFormatKind.MESSAGE_DETAIL)
val body = state.messageBody.linkify(host.listener)
val bindingOptions = spanUtils.getBindingOptions(body)
val locationData = state.timelineEvent()?.root?.getClearContent()?.toModel<MessageLocationContent>(catchError = true)?.let {
LocationData.create(it.getUri())
}
val locationUrl = state.timelineEvent()?.root?.getClearContent()
?.toModel<MessageLocationContent>(catchError = true)
?.toLocationData()
?.let { urlMapProvider.buildStaticMapUrl(it, INITIAL_MAP_ZOOM_IN_TIMELINE, 1200, 800) }
bottomSheetMessagePreviewItem {
id("preview")
avatarRenderer(host.avatarRenderer)
@ -89,7 +94,7 @@ class MessageActionsEpoxyController @Inject constructor(
body(body.toEpoxyCharSequence())
bodyDetails(host.eventDetailsFormatter.format(state.timelineEvent()?.root)?.toEpoxyCharSequence())
time(formattedDate)
locationData(locationData)
locationUrl(locationUrl)
locationPinProvider(host.locationPinProvider)
}

View file

@ -16,6 +16,7 @@
package im.vector.app.features.home.room.detail.timeline.factory
import android.content.res.Resources
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.Spanned
@ -33,7 +34,6 @@ import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.core.utils.containsOnlyEmojis
import im.vector.app.features.home.room.detail.RoomDetailAction
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
@ -71,7 +71,9 @@ import im.vector.app.features.html.EventHtmlRenderer
import im.vector.app.features.html.PillsPostProcessor
import im.vector.app.features.html.SpanUtils
import im.vector.app.features.html.VectorHtmlCompressor
import im.vector.app.features.location.LocationData
import im.vector.app.features.location.INITIAL_MAP_ZOOM_IN_TIMELINE
import im.vector.app.features.location.UrlMapProvider
import im.vector.app.features.location.toLocationData
import im.vector.app.features.media.ImageContentRenderer
import im.vector.app.features.media.VideoContentRenderer
import im.vector.app.features.settings.VectorPreferences
@ -127,7 +129,10 @@ class MessageItemFactory @Inject constructor(
private val session: Session,
private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker,
private val locationPinProvider: LocationPinProvider,
private val vectorPreferences: VectorPreferences) {
private val vectorPreferences: VectorPreferences,
private val urlMapProvider: UrlMapProvider,
private val resources: Resources
) {
// TODO inject this properly?
private var roomId: String = ""
@ -182,7 +187,7 @@ class MessageItemFactory @Inject constructor(
is MessagePollContent -> buildPollItem(messageContent, informationData, highlight, callback, attributes)
is MessageLocationContent -> {
if (vectorPreferences.labsRenderLocationsInTimeline()) {
buildLocationItem(messageContent, informationData, highlight, callback, attributes)
buildLocationItem(messageContent, informationData, highlight, attributes)
} else {
buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes)
}
@ -194,27 +199,21 @@ class MessageItemFactory @Inject constructor(
private fun buildLocationItem(locationContent: MessageLocationContent,
informationData: MessageInformationData,
highlight: Boolean,
callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes): MessageLocationItem? {
val geoUri = locationContent.getUri()
val locationData = LocationData.create(geoUri)
val width = resources.displayMetrics.widthPixels - dimensionConverter.dpToPx(60)
val height = dimensionConverter.dpToPx(200)
val mapCallback: MessageLocationItem.Callback = object : MessageLocationItem.Callback {
override fun onMapClicked() {
locationData?.let {
callback?.onTimelineItemAction(RoomDetailAction.ShowLocation(it, informationData.senderId))
}
}
val locationUrl = locationContent.toLocationData()?.let {
urlMapProvider.buildStaticMapUrl(it, INITIAL_MAP_ZOOM_IN_TIMELINE, width, height)
}
return MessageLocationItem_()
.attributes(attributes)
.locationData(locationData)
.locationUrl(locationUrl)
.userId(informationData.senderId)
.locationPinProvider(locationPinProvider)
.highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline)
.callback(mapCallback)
}
private fun buildPollItem(pollContent: MessagePollContent,

View file

@ -28,6 +28,7 @@ import im.vector.app.core.glide.GlideApp
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.home.AvatarRenderer
import org.matrix.android.sdk.api.util.toMatrixItem
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@ -54,22 +55,36 @@ class LocationPinProvider @Inject constructor(
val size = dimensionConverter.dpToPx(44)
avatarRenderer.render(glideRequests, it, object : CustomTarget<Drawable>(size, size) {
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
val bgUserPin = ContextCompat.getDrawable(context, R.drawable.bg_map_user_pin)!!
val layerDrawable = LayerDrawable(arrayOf(bgUserPin, resource))
val horizontalInset = dimensionConverter.dpToPx(4)
val topInset = dimensionConverter.dpToPx(4)
val bottomInset = dimensionConverter.dpToPx(8)
layerDrawable.setLayerInset(1, horizontalInset, topInset, horizontalInset, bottomInset)
cache[userId] = layerDrawable
callback(layerDrawable)
Timber.d("## Location: onResourceReady")
val pinDrawable = createPinDrawable(resource)
cache[userId] = pinDrawable
callback(pinDrawable)
}
override fun onLoadCleared(placeholder: Drawable?) {
// Is it possible? Put placeholder instead?
// FIXME The doc says it has to be implemented and should free resources
Timber.d("## Location: onLoadCleared")
}
override fun onLoadFailed(errorDrawable: Drawable?) {
Timber.w("## Location: onLoadFailed")
errorDrawable ?: return
val pinDrawable = createPinDrawable(errorDrawable)
cache[userId] = pinDrawable
callback(pinDrawable)
}
})
}
}
private fun createPinDrawable(drawable: Drawable): Drawable {
val bgUserPin = ContextCompat.getDrawable(context, R.drawable.bg_map_user_pin)!!
val layerDrawable = LayerDrawable(arrayOf(bgUserPin, drawable))
val horizontalInset = dimensionConverter.dpToPx(4)
val topInset = dimensionConverter.dpToPx(4)
val bottomInset = dimensionConverter.dpToPx(8)
layerDrawable.setLayerInset(1, horizontalInset, topInset, horizontalInset, bottomInset)
return layerDrawable
}
}

View file

@ -16,28 +16,19 @@
package im.vector.app.features.home.room.detail.timeline.item
import android.widget.FrameLayout
import androidx.constraintlayout.widget.ConstraintLayout
import android.widget.ImageView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import com.bumptech.glide.request.RequestOptions
import im.vector.app.R
import im.vector.app.core.epoxy.onClick
import im.vector.app.core.glide.GlideApp
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import im.vector.app.features.location.LocationData
import im.vector.app.features.location.MapTilerMapView
@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class MessageLocationItem : AbsMessageItem<MessageLocationItem.Holder>() {
interface Callback {
fun onMapClicked()
}
@EpoxyAttribute
var callback: Callback? = null
@EpoxyAttribute
var locationData: LocationData? = null
var locationUrl: String? = null
@EpoxyAttribute
var userId: String? = null
@ -47,37 +38,31 @@ abstract class MessageLocationItem : AbsMessageItem<MessageLocationItem.Holder>(
override fun bind(holder: Holder) {
super.bind(holder)
renderSendState(holder.mapViewContainer, null)
renderSendState(holder.view, null)
val location = locationData ?: return
val location = locationUrl ?: return
val locationOwnerId = userId ?: return
holder.clickableMapArea.onClick {
callback?.onMapClicked()
}
GlideApp.with(holder.staticMapImageView)
.load(location)
.apply(RequestOptions.centerCropTransform())
.into(holder.staticMapImageView)
holder.mapView.apply {
initialize {
zoomToLocation(location.latitude, location.longitude, INITIAL_ZOOM)
locationPinProvider?.create(locationOwnerId) { pinDrawable ->
addPinToMap(locationOwnerId, pinDrawable)
updatePinLocation(locationOwnerId, location.latitude, location.longitude)
}
}
locationPinProvider?.create(locationOwnerId) { pinDrawable ->
GlideApp.with(holder.staticMapPinImageView)
.load(pinDrawable)
.into(holder.staticMapPinImageView)
}
}
override fun getViewType() = STUB_ID
class Holder : AbsMessageItem.Holder(STUB_ID) {
val mapViewContainer by bind<ConstraintLayout>(R.id.mapViewContainer)
val mapView by bind<MapTilerMapView>(R.id.mapView)
val clickableMapArea by bind<FrameLayout>(R.id.clickableMapArea)
val staticMapImageView by bind<ImageView>(R.id.staticMapImageView)
val staticMapPinImageView by bind<ImageView>(R.id.staticMapPinImageView)
}
companion object {
private const val STUB_ID = R.id.messageContentLocationStub
private const val INITIAL_ZOOM = 15.0
}
}

View file

@ -16,6 +16,10 @@
package im.vector.app.features.location
const val INITIAL_MAP_ZOOM = 15.0
const val MIN_TIME_MILLIS_TO_UPDATE_LOCATION = 1 * 60 * 1000L // every 1 minute
const val MIN_DISTANCE_METERS_TO_UPDATE_LOCATION = 10f
const val MAP_BASE_URL = "https://api.maptiler.com/maps/streets/style.json"
const val STATIC_MAP_BASE_URL = "https://api.maptiler.com/maps/basic/static/"
const val INITIAL_MAP_ZOOM_IN_PREVIEW = 15.0
const val INITIAL_MAP_ZOOM_IN_TIMELINE = 17.0
const val MIN_TIME_TO_UPDATE_LOCATION_MILLIS = 5 * 1_000L // every 5 seconds
const val MIN_DISTANCE_TO_UPDATE_LOCATION_METERS = 10f

View file

@ -17,41 +17,43 @@
package im.vector.app.features.location
import android.os.Parcelable
import androidx.annotation.VisibleForTesting
import kotlinx.parcelize.Parcelize
import org.matrix.android.sdk.api.session.room.model.message.MessageLocationContent
@Parcelize
data class LocationData(
val latitude: Double,
val longitude: Double,
val uncertainty: Double?
) : Parcelable {
) : Parcelable
companion object {
/**
* Creates location data from geo uri
* @param geoUri geo:latitude,longitude;uncertainty
* @return location data or null if geo uri is not valid
*/
fun create(geoUri: String): LocationData? {
val geoParts = geoUri
.split(":")
.takeIf { it.firstOrNull() == "geo" }
?.getOrNull(1)
?.split(",")
val latitude = geoParts?.firstOrNull()
val geoTailParts = geoParts?.getOrNull(1)?.split(";")
val longitude = geoTailParts?.firstOrNull()
val uncertainty = geoTailParts?.getOrNull(1)?.replace("u=", "")
return if (latitude != null && longitude != null) {
LocationData(
latitude = latitude.toDouble(),
longitude = longitude.toDouble(),
uncertainty = uncertainty?.toDouble()
)
} else null
}
}
/**
* Creates location data from a LocationContent
* "geo:40.05,29.24;30" -> LocationData(40.05, 29.24, 30)
* @return location data or null if geo uri is not valid
*/
fun MessageLocationContent.toLocationData(): LocationData? {
return parseGeo(getBestGeoUri())
}
@VisibleForTesting
fun parseGeo(geo: String): LocationData? {
val geoParts = geo
.split(":")
.takeIf { it.firstOrNull() == "geo" }
?.getOrNull(1)
?.split(";") ?: return null
val gpsParts = geoParts.getOrNull(0)?.split(",") ?: return null
val lat = gpsParts.getOrNull(0)?.toDoubleOrNull() ?: return null
val lng = gpsParts.getOrNull(1)?.toDoubleOrNull() ?: return null
val uncertainty = geoParts.getOrNull(1)?.replace("u=", "")?.toDoubleOrNull()
return LocationData(
latitude = lat,
longitude = lng,
uncertainty = uncertainty
)
}

View file

@ -21,20 +21,30 @@ import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.args
import com.mapbox.mapboxsdk.maps.MapView
import im.vector.app.R
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.utils.openLocation
import im.vector.app.databinding.FragmentLocationPreviewBinding
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import java.lang.ref.WeakReference
import javax.inject.Inject
/**
* TODO Move locationPinProvider to a ViewModel
*/
class LocationPreviewFragment @Inject constructor(
private val urlMapProvider: UrlMapProvider,
private val locationPinProvider: LocationPinProvider
) : VectorBaseFragment<FragmentLocationPreviewBinding>() {
private val args: LocationSharingArgs by args()
// Keep a ref to handle properly the onDestroy callback
private var mapView: WeakReference<MapView>? = null
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLocationPreviewBinding {
return FragmentLocationPreviewBinding.inflate(layoutInflater, container, false)
}
@ -42,11 +52,15 @@ class LocationPreviewFragment @Inject constructor(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
views.mapView.initialize {
if (isAdded) {
onMapReady()
}
}
mapView = WeakReference(views.mapView)
views.mapView.onCreate(savedInstanceState)
views.mapView.initialize(urlMapProvider.mapUrl)
loadPinDrawable()
}
override fun onResume() {
super.onResume()
views.mapView.onResume()
}
override fun onPause() {
@ -54,11 +68,32 @@ class LocationPreviewFragment @Inject constructor(
super.onPause()
}
override fun onLowMemory() {
views.mapView.onLowMemory()
super.onLowMemory()
}
override fun onStart() {
super.onStart()
views.mapView.onStart()
}
override fun onStop() {
views.mapView.onStop()
super.onStop()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
views.mapView.onSaveInstanceState(outState)
}
override fun onDestroy() {
mapView?.get()?.onDestroy()
mapView?.clear()
super.onDestroy()
}
override fun getMenuRes() = R.menu.menu_location_preview
override fun onOptionsItemSelected(item: MenuItem): Boolean {
@ -76,18 +111,20 @@ class LocationPreviewFragment @Inject constructor(
openLocation(requireActivity(), location.latitude, location.longitude)
}
private fun onMapReady() {
if (!isAdded) return
private fun loadPinDrawable() {
val location = args.initialLocationData ?: return
val userId = args.locationOwnerId
locationPinProvider.create(userId) { pinDrawable ->
views.mapView.apply {
zoomToLocation(location.latitude, location.longitude, INITIAL_MAP_ZOOM)
deleteAllPins()
addPinToMap(userId, pinDrawable)
updatePinLocation(userId, location.latitude, location.longitude)
lifecycleScope.launchWhenResumed {
views.mapView.render(
MapState(
zoomOnlyOnce = true,
pinLocationData = location,
pinId = args.locationOwnerId,
pinDrawable = pinDrawable
)
)
}
}
}

View file

@ -19,7 +19,5 @@ package im.vector.app.features.location
import im.vector.app.core.platform.VectorViewModelAction
sealed class LocationSharingAction : VectorViewModelAction {
data class OnLocationUpdate(val locationData: LocationData) : LocationSharingAction()
object OnShareLocation : LocationSharingAction()
object OnLocationProviderIsNotAvailable : LocationSharingAction()
}

View file

@ -20,29 +20,29 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isGone
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.mapbox.mapboxsdk.maps.MapView
import im.vector.app.R
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentLocationSharingBinding
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import org.matrix.android.sdk.api.session.Session
import java.lang.ref.WeakReference
import javax.inject.Inject
/**
* We should consider using SupportMapFragment for a out of the box lifecycle handling
*/
class LocationSharingFragment @Inject constructor(
private val locationTracker: LocationTracker,
private val session: Session,
private val locationPinProvider: LocationPinProvider
) : VectorBaseFragment<FragmentLocationSharingBinding>(), LocationTracker.Callback {
init {
locationTracker.callback = this
}
private val urlMapProvider: UrlMapProvider
) : VectorBaseFragment<FragmentLocationSharingBinding>() {
private val viewModel: LocationSharingViewModel by fragmentViewModel()
private var lastZoomValue: Double = -1.0
// Keep a ref to handle properly the onDestroy callback
private var mapView: WeakReference<MapView>? = null
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLocationSharingBinding {
return FragmentLocationSharingBinding.inflate(inflater, container, false)
@ -51,11 +51,9 @@ class LocationSharingFragment @Inject constructor(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
views.mapView.initialize {
if (isAdded) {
onMapReady()
}
}
mapView = WeakReference(views.mapView)
views.mapView.onCreate(savedInstanceState)
views.mapView.initialize(urlMapProvider.mapUrl)
views.shareLocationContainer.debouncedClicks {
viewModel.handle(LocationSharingAction.OnShareLocation)
@ -63,54 +61,48 @@ class LocationSharingFragment @Inject constructor(
viewModel.observeViewEvents {
when (it) {
LocationSharingViewEvents.LocationNotAvailableError -> handleLocationNotAvailableError()
LocationSharingViewEvents.Close -> activity?.finish()
LocationSharingViewEvents.LocationNotAvailableError -> handleLocationNotAvailableError()
LocationSharingViewEvents.Close -> activity?.finish()
}.exhaustive
}
}
override fun onResume() {
super.onResume()
views.mapView.onResume()
}
override fun onPause() {
views.mapView.onPause()
super.onPause()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
views.mapView.onSaveInstanceState(outState)
}
override fun onStart() {
super.onStart()
views.mapView.onStart()
}
override fun onStop() {
views.mapView.onStop()
super.onStop()
}
override fun onLowMemory() {
super.onLowMemory()
views.mapView.onLowMemory()
}
override fun onDestroy() {
locationTracker.stop()
mapView?.get()?.onDestroy()
mapView?.clear()
super.onDestroy()
}
private fun onMapReady() {
if (!isAdded) return
locationPinProvider.create(session.myUserId) {
views.mapView.addPinToMap(
pinId = USER_PIN_NAME,
image = it,
)
// All set, start location tracker
locationTracker.start()
}
}
override fun onLocationUpdate(locationData: LocationData) {
lastZoomValue = if (lastZoomValue == -1.0) INITIAL_MAP_ZOOM else views.mapView.getCurrentZoom() ?: INITIAL_MAP_ZOOM
views.mapView.zoomToLocation(locationData.latitude, locationData.longitude, lastZoomValue)
views.mapView.deleteAllPins()
views.mapView.updatePinLocation(USER_PIN_NAME, locationData.latitude, locationData.longitude)
viewModel.handle(LocationSharingAction.OnLocationUpdate(locationData))
}
override fun onLocationProviderIsNotAvailable() {
viewModel.handle(LocationSharingAction.OnLocationProviderIsNotAvailable)
}
private fun handleLocationNotAvailableError() {
MaterialAlertDialogBuilder(requireActivity())
.setTitle(R.string.location_not_available_dialog_title)
@ -118,9 +110,15 @@ class LocationSharingFragment @Inject constructor(
.setPositiveButton(R.string.ok) { _, _ ->
activity?.finish()
}
.setCancelable(false)
.show()
}
override fun invalidate() = withState(viewModel) { state ->
views.mapView.render(state.toMapState())
views.shareLocationGpsLoading.isGone = state.lastKnownLocation != null
}
companion object {
const val USER_PIN_NAME = "USER_PIN_NAME"
}

View file

@ -24,12 +24,15 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import org.matrix.android.sdk.api.session.Session
class LocationSharingViewModel @AssistedInject constructor(
@Assisted private val initialState: LocationSharingViewState,
session: Session
) : VectorViewModel<LocationSharingViewState, LocationSharingAction, LocationSharingViewEvents>(initialState) {
private val locationTracker: LocationTracker,
private val locationPinProvider: LocationPinProvider,
private val session: Session
) : VectorViewModel<LocationSharingViewState, LocationSharingAction, LocationSharingViewEvents>(initialState), LocationTracker.Callback {
private val room = session.getRoom(initialState.roomId)!!
@ -38,14 +41,31 @@ class LocationSharingViewModel @AssistedInject constructor(
override fun create(initialState: LocationSharingViewState): LocationSharingViewModel
}
companion object : MavericksViewModelFactory<LocationSharingViewModel, LocationSharingViewState> by hiltMavericksViewModelFactory() {
companion object : MavericksViewModelFactory<LocationSharingViewModel, LocationSharingViewState> by hiltMavericksViewModelFactory()
init {
locationTracker.start(this)
createPin()
}
private fun createPin() {
locationPinProvider.create(session.myUserId) {
setState {
copy(
pinDrawable = it
)
}
}
}
override fun onCleared() {
super.onCleared()
locationTracker.stop()
}
override fun handle(action: LocationSharingAction) {
when (action) {
is LocationSharingAction.OnLocationUpdate -> handleLocationUpdate(action.locationData)
LocationSharingAction.OnShareLocation -> handleShareLocation()
LocationSharingAction.OnLocationProviderIsNotAvailable -> handleLocationProviderIsNotAvailable()
LocationSharingAction.OnShareLocation -> handleShareLocation()
}.exhaustive
}
@ -62,13 +82,13 @@ class LocationSharingViewModel @AssistedInject constructor(
}
}
private fun handleLocationUpdate(locationData: LocationData) {
override fun onLocationUpdate(locationData: LocationData) {
setState {
copy(lastKnownLocation = locationData)
}
}
private fun handleLocationProviderIsNotAvailable() {
override fun onLocationProviderIsNotAvailable() {
_viewEvents.post(LocationSharingViewEvents.LocationNotAvailableError)
}
}

View file

@ -16,6 +16,7 @@
package im.vector.app.features.location
import android.graphics.drawable.Drawable
import androidx.annotation.StringRes
import com.airbnb.mvrx.MavericksState
import im.vector.app.R
@ -28,7 +29,8 @@ enum class LocationSharingMode(@StringRes val titleRes: Int) {
data class LocationSharingViewState(
val roomId: String,
val mode: LocationSharingMode,
val lastKnownLocation: LocationData? = null
val lastKnownLocation: LocationData? = null,
val pinDrawable: Drawable? = null
) : MavericksState {
constructor(locationSharingArgs: LocationSharingArgs) : this(
@ -36,3 +38,10 @@ data class LocationSharingViewState(
mode = locationSharingArgs.mode
)
}
fun LocationSharingViewState.toMapState() = MapState(
zoomOnlyOnce = true,
pinLocationData = lastKnownLocation,
pinId = LocationSharingFragment.USER_PIN_NAME,
pinDrawable = pinDrawable
)

View file

@ -19,70 +19,108 @@ package im.vector.app.features.location
import android.Manifest
import android.content.Context
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import androidx.annotation.RequiresPermission
import androidx.core.content.getSystemService
import androidx.core.location.LocationListenerCompat
import im.vector.app.BuildConfig
import timber.log.Timber
import javax.inject.Inject
class LocationTracker @Inject constructor(
private val context: Context
) : LocationListener {
context: Context
) : LocationListenerCompat {
private val locationManager = context.getSystemService<LocationManager>()
interface Callback {
fun onLocationUpdate(locationData: LocationData)
fun onLocationProviderIsNotAvailable()
}
private var locationManager: LocationManager? = null
var callback: Callback? = null
private var callback: Callback? = null
private var hasGpsProviderLiveLocation = false
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
fun start() {
val locationManager = context.getSystemService<LocationManager>()
fun start(callback: Callback?) {
Timber.d("## LocationTracker. start()")
hasGpsProviderLiveLocation = false
this.callback = callback
locationManager?.let {
val isGpsEnabled = it.isProviderEnabled(LocationManager.GPS_PROVIDER)
val isNetworkEnabled = it.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
val provider = when {
isGpsEnabled -> LocationManager.GPS_PROVIDER
isNetworkEnabled -> LocationManager.NETWORK_PROVIDER
else -> {
callback?.onLocationProviderIsNotAvailable()
Timber.v("## LocationTracker. There is no location provider available")
return
}
}
// Send last known location without waiting location updates
it.getLastKnownLocation(provider)?.let { lastKnownLocation ->
callback?.onLocationUpdate(lastKnownLocation.toLocationData())
}
it.requestLocationUpdates(
provider,
MIN_TIME_MILLIS_TO_UPDATE_LOCATION,
MIN_DISTANCE_METERS_TO_UPDATE_LOCATION,
this
)
} ?: run {
if (locationManager == null) {
callback?.onLocationProviderIsNotAvailable()
Timber.v("## LocationTracker. LocationManager is not available")
return
}
locationManager.allProviders
.takeIf { it.isNotEmpty() }
// Take GPS first
?.sortedByDescending { if (it == LocationManager.GPS_PROVIDER) 1 else 0 }
?.forEach { provider ->
Timber.d("## LocationTracker. track location using $provider")
// Send last known location without waiting location updates
locationManager.getLastKnownLocation(provider)?.let { lastKnownLocation ->
if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) {
Timber.d("## LocationTracker. lastKnownLocation: $lastKnownLocation")
} else {
Timber.d("## LocationTracker. lastKnownLocation: ${lastKnownLocation.provider}")
}
notifyLocation(lastKnownLocation, isLive = false)
}
locationManager.requestLocationUpdates(
provider,
MIN_TIME_TO_UPDATE_LOCATION_MILLIS,
MIN_DISTANCE_TO_UPDATE_LOCATION_METERS,
this
)
}
?: run {
callback?.onLocationProviderIsNotAvailable()
Timber.v("## LocationTracker. There is no location provider available")
}
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
fun stop() {
Timber.d("## LocationTracker. stop()")
locationManager?.removeUpdates(this)
callback = null
}
override fun onLocationChanged(location: Location) {
if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) {
Timber.d("## LocationTracker. onLocationChanged: $location")
} else {
Timber.d("## LocationTracker. onLocationChanged: ${location.provider}")
}
notifyLocation(location, isLive = true)
}
private fun notifyLocation(location: Location, isLive: Boolean) {
when (location.provider) {
LocationManager.GPS_PROVIDER -> {
hasGpsProviderLiveLocation = isLive
}
else -> {
if (hasGpsProviderLiveLocation) {
// Ignore this update
Timber.d("## LocationTracker. ignoring location from ${location.provider}, we have gps live location")
return
}
}
}
callback?.onLocationUpdate(location.toLocationData())
}
override fun onProviderDisabled(provider: String) {
Timber.d("## LocationTracker. onProviderDisabled: $provider")
callback?.onLocationProviderIsNotAvailable()
}
private fun Location.toLocationData(): LocationData {
return LocationData(latitude, longitude, accuracy.toDouble())
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2021 New Vector Ltd
* 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.
@ -18,15 +18,9 @@ package im.vector.app.features.location
import android.graphics.drawable.Drawable
interface VectorMapView {
fun initialize(onMapReady: () -> Unit)
fun addPinToMap(pinId: String, image: Drawable)
fun updatePinLocation(pinId: String, latitude: Double, longitude: Double)
fun deleteAllPins()
fun zoomToLocation(latitude: Double, longitude: Double, zoom: Double)
fun getCurrentZoom(): Double?
fun onClick(callback: () -> Unit)
}
data class MapState(
val zoomOnlyOnce: Boolean,
val pinLocationData: LocationData? = null,
val pinId: String,
val pinDrawable: Drawable? = null
)

View file

@ -17,7 +17,6 @@
package im.vector.app.features.location
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import com.mapbox.mapboxsdk.camera.CameraPosition
import com.mapbox.mapboxsdk.geometry.LatLng
@ -27,65 +26,76 @@ import com.mapbox.mapboxsdk.maps.Style
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
import com.mapbox.mapboxsdk.style.layers.Property
import im.vector.app.BuildConfig
import timber.log.Timber
class MapTilerMapView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : MapView(context, attrs, defStyleAttr), VectorMapView {
) : MapView(context, attrs, defStyleAttr) {
private var map: MapboxMap? = null
private var symbolManager: SymbolManager? = null
private var style: Style? = null
private var pendingState: MapState? = null
override fun initialize(onMapReady: () -> Unit) {
data class MapRefs(
val map: MapboxMap,
val symbolManager: SymbolManager,
val style: Style
)
private var mapRefs: MapRefs? = null
private var initZoomDone = false
/**
* For location fragments
*/
fun initialize(url: String) {
Timber.d("## Location: initialize")
getMapAsync { map ->
map.setStyle(styleUrl) { style ->
this.symbolManager = SymbolManager(this, map, style)
this.map = map
this.style = style
onMapReady()
map.setStyle(url) { style ->
mapRefs = MapRefs(
map,
SymbolManager(this, map, style),
style
)
pendingState?.let { render(it) }
pendingState = null
}
}
}
override fun addPinToMap(pinId: String, image: Drawable) {
style?.addImage(pinId, image)
}
fun render(state: MapState) {
val safeMapRefs = mapRefs ?: return Unit.also {
pendingState = state
}
override fun updatePinLocation(pinId: String, latitude: Double, longitude: Double) {
symbolManager?.create(
SymbolOptions()
.withLatLng(LatLng(latitude, longitude))
.withIconImage(pinId)
.withIconAnchor(Property.ICON_ANCHOR_BOTTOM)
)
}
state.pinDrawable?.let { pinDrawable ->
if (!safeMapRefs.style.isFullyLoaded ||
safeMapRefs.style.getImage(state.pinId) == null) {
safeMapRefs.style.addImage(state.pinId, pinDrawable)
}
}
override fun deleteAllPins() {
symbolManager?.deleteAll()
}
state.pinLocationData?.let { locationData ->
if (!initZoomDone || !state.zoomOnlyOnce) {
zoomToLocation(locationData.latitude, locationData.longitude)
initZoomDone = true
}
override fun zoomToLocation(latitude: Double, longitude: Double, zoom: Double) {
map?.cameraPosition = CameraPosition.Builder()
.target(LatLng(latitude, longitude))
.zoom(zoom)
.build()
}
override fun getCurrentZoom(): Double? {
return map?.cameraPosition?.zoom
}
override fun onClick(callback: () -> Unit) {
map?.addOnMapClickListener {
callback()
true
safeMapRefs.symbolManager.deleteAll()
safeMapRefs.symbolManager.create(
SymbolOptions()
.withLatLng(LatLng(locationData.latitude, locationData.longitude))
.withIconImage(state.pinId)
.withIconAnchor(Property.ICON_ANCHOR_BOTTOM)
)
}
}
companion object {
private const val styleUrl = "https://api.maptiler.com/maps/streets/style.json?key=${BuildConfig.mapTilerKey}"
private fun zoomToLocation(latitude: Double, longitude: Double) {
Timber.d("## Location: zoomToLocation")
mapRefs?.map?.cameraPosition = CameraPosition.Builder()
.target(LatLng(latitude, longitude))
.zoom(INITIAL_MAP_ZOOM_IN_PREVIEW)
.build()
}
}

View file

@ -0,0 +1,58 @@
/*
* 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 im.vector.app.features.location
import android.content.res.Resources
import im.vector.app.BuildConfig
import im.vector.app.R
import javax.inject.Inject
class UrlMapProvider @Inject constructor(
private val resources: Resources
) {
private val keyParam = "?key=${BuildConfig.mapTilerKey}"
// This is static so no need for a fun
val mapUrl = buildString {
append(MAP_BASE_URL)
append(keyParam)
}
fun buildStaticMapUrl(locationData: LocationData,
zoom: Double,
width: Int,
height: Int): String {
return buildString {
append(STATIC_MAP_BASE_URL)
append(locationData.longitude)
append(",")
append(locationData.latitude)
append(",")
append(zoom)
append("/")
append(width)
append("x")
append(height)
append(".png")
append(keyParam)
if (!resources.getBoolean(R.bool.is_rtl)) {
// On LTR languages we want the legal mentions to be displayed on the bottom left of the image
append("&attribution=bottomleft")
}
}
}
}

View file

@ -48,4 +48,13 @@
android:textColor="?colorPrimary"
android:textStyle="bold" />
<ProgressBar
android:id="@+id/shareLocationGpsLoading"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
app:layout_constraintBottom_toBottomOf="@id/shareLocationContainer"
app:layout_constraintEnd_toEndOf="@id/shareLocationContainer"
app:layout_constraintTop_toTopOf="@id/shareLocationContainer" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -103,18 +103,34 @@
tools:text="1080 x 1024 - 43s - 12kB"
tools:visibility="visible" />
<im.vector.app.features.location.MapTilerMapView
android:id="@+id/bottom_sheet_message_preview_location"
<com.google.android.material.card.MaterialCardView
android:id="@+id/mapViewContainer"
android:layout_width="0dp"
android:layout_height="200dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:contentDescription="@string/attachment_type_location"
android:scaleType="centerCrop"
android:visibility="gone"
app:cardCornerRadius="8dp"
app:layout_constraintEnd_toEndOf="@id/bottom_sheet_message_preview_timestamp"
app:layout_constraintStart_toStartOf="@id/bottom_sheet_message_preview_sender"
app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message_preview_sender"
app:mapbox_renderTextureMode="true"
tools:visibility="visible" />
tools:alpha="0.3"
tools:visibility="visible">
<ImageView
android:id="@+id/staticMapImageView"
android:layout_width="match_parent"
android:layout_height="200dp"
android:contentDescription="@string/a11y_static_map_image" />
<ImageView
android:id="@+id/staticMapPinImageView"
android:layout_width="51dp"
android:layout_height="55dp"
android:layout_gravity="center"
android:layout_marginBottom="28dp"
android:importantForAccessibility="no"
android:src="@drawable/bg_map_user_pin" />
</com.google.android.material.card.MaterialCardView>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -6,30 +6,19 @@
android:layout_height="wrap_content"
app:cardCornerRadius="8dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/mapViewContainer"
<ImageView
android:id="@+id/staticMapImageView"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="200dp"
android:contentDescription="@string/a11y_static_map_image" />
<im.vector.app.features.location.MapTilerMapView
android:id="@+id/mapView"
android:layout_width="0dp"
android:layout_height="200dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:mapbox_renderTextureMode="true" />
<FrameLayout
android:id="@+id/clickableMapArea"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="@id/mapView"
app:layout_constraintEnd_toEndOf="@id/mapView"
app:layout_constraintStart_toStartOf="@id/mapView"
app:layout_constraintTop_toTopOf="@id/mapView" />
</androidx.constraintlayout.widget.ConstraintLayout>
<ImageView
android:id="@+id/staticMapPinImageView"
android:layout_width="51dp"
android:layout_height="55dp"
android:layout_gravity="center"
android:layout_marginBottom="28dp"
android:importantForAccessibility="no"
android:src="@drawable/bg_map_user_pin" />
</com.google.android.material.card.MaterialCardView>

View file

@ -3711,6 +3711,7 @@
<string name="location_activity_title_static_sharing">Share location</string>
<string name="location_activity_title_preview">Location</string>
<string name="a11y_location_share_icon">Share location</string>
<string name="a11y_static_map_image">Map</string>
<string name="location_share">Share location</string>
<string name="template_location_not_available_dialog_title">${app_name} could not access your location</string>
<string name="template_location_not_available_dialog_content">${app_name} could not access your location. Please try again later.</string>

View file

@ -0,0 +1,60 @@
/*
* 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 im.vector.app.features.location
import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldBeNull
import org.junit.Test
class LocationDataTest {
@Test
fun validCases() {
parseGeo("geo:12.34,56.78;13.56") shouldBeEqualTo
LocationData(latitude = 12.34, longitude = 56.78, uncertainty = 13.56)
parseGeo("geo:12.34,56.78") shouldBeEqualTo
LocationData(latitude = 12.34, longitude = 56.78, uncertainty = null)
// Error is ignored in case of invalid uncertainty
parseGeo("geo:12.34,56.78;13.5z6") shouldBeEqualTo
LocationData(latitude = 12.34, longitude = 56.78, uncertainty = null)
parseGeo("geo:12.34,56.78;13. 56") shouldBeEqualTo
LocationData(latitude = 12.34, longitude = 56.78, uncertainty = null)
// Space are ignored (trim)
parseGeo("geo: 12.34,56.78;13.56") shouldBeEqualTo
LocationData(latitude = 12.34, longitude = 56.78, uncertainty = 13.56)
parseGeo("geo:12.34,56.78; 13.56") shouldBeEqualTo
LocationData(latitude = 12.34, longitude = 56.78, uncertainty = 13.56)
}
@Test
fun invalidCases() {
parseGeo("").shouldBeNull()
parseGeo("geo").shouldBeNull()
parseGeo("geo:").shouldBeNull()
parseGeo("geo:12.34").shouldBeNull()
parseGeo("geo:12.34;13.56").shouldBeNull()
parseGeo("gea:12.34,56.78;13.56").shouldBeNull()
parseGeo("geo:12.x34,56.78;13.56").shouldBeNull()
parseGeo("geo:12.34,56.7y8;13.56").shouldBeNull()
// Spaces are not ignored if inside the numbers
parseGeo("geo:12.3 4,56.78;13.56").shouldBeNull()
parseGeo("geo:12.34,56.7 8;13.56").shouldBeNull()
// Or in the protocol part
parseGeo(" geo:12.34,56.78;13.56").shouldBeNull()
parseGeo("ge o:12.34,56.78;13.56").shouldBeNull()
parseGeo("geo :12.34,56.78;13.56").shouldBeNull()
}
}