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) --> <!-- Created to detect what has to be implemented (especially in the settings) -->
<bool name="false_not_implemented">false</bool> <bool name="false_not_implemented">false</bool>
<bool name="is_rtl">false</bool>
</resources> </resources>

View File

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

View File

@ -17,24 +17,25 @@
package im.vector.app.core.epoxy.bottomsheet package im.vector.app.core.epoxy.bottomsheet
import android.text.method.MovementMethod import android.text.method.MovementMethod
import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
import com.bumptech.glide.request.RequestOptions
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener import im.vector.app.core.epoxy.ClickListener
import im.vector.app.core.epoxy.VectorEpoxyHolder import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.epoxy.onClick import im.vector.app.core.epoxy.onClick
import im.vector.app.core.extensions.setTextOrHide 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.displayname.getBestName
import im.vector.app.features.home.AvatarRenderer 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.helper.LocationPinProvider
import im.vector.app.features.home.room.detail.timeline.item.BindingOptions 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.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.app.features.media.ImageContentRenderer
import im.vector.lib.core.utils.epoxy.charsequence.EpoxyCharSequence import im.vector.lib.core.utils.epoxy.charsequence.EpoxyCharSequence
import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.MatrixItem
@ -70,7 +71,7 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel<BottomSheetMessa
var time: String? = null var time: String? = null
@EpoxyAttribute @EpoxyAttribute
var locationData: LocationData? = null var locationUrl: String? = null
@EpoxyAttribute @EpoxyAttribute
var locationPinProvider: LocationPinProvider? = null var locationPinProvider: LocationPinProvider? = null
@ -97,17 +98,21 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel<BottomSheetMessa
body.charSequence.findPillsAndProcess(coroutineScope) { it.bind(holder.body) } body.charSequence.findPillsAndProcess(coroutineScope) { it.bind(holder.body) }
holder.timestamp.setTextOrHide(time) holder.timestamp.setTextOrHide(time)
holder.mapView.isVisible = locationData != null if (locationUrl == null) {
holder.body.isVisible = locationData == null holder.body.isVisible = true
locationData?.let { location -> holder.mapViewContainer.isVisible = false
holder.mapView.initialize { } else {
if (holder.view.isAttachedToWindow) { holder.body.isVisible = false
holder.mapView.zoomToLocation(location.latitude, location.longitude, 15.0) holder.mapViewContainer.isVisible = true
GlideApp.with(holder.staticMapImageView)
.load(locationUrl)
.apply(RequestOptions.centerCropTransform())
.into(holder.staticMapImageView)
locationPinProvider?.create(matrixItem.id) { pinDrawable -> locationPinProvider?.create(matrixItem.id) { pinDrawable ->
holder.mapView.addPinToMap(matrixItem.id, pinDrawable) GlideApp.with(holder.staticMapPinImageView)
holder.mapView.updatePinLocation(matrixItem.id, location.latitude, location.longitude) .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 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 timestamp by bind<TextView>(R.id.bottom_sheet_message_preview_timestamp)
val imagePreview by bind<ImageView>(R.id.bottom_sheet_message_preview_image) 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 android.view.View
import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.features.call.conference.ConferenceEvent 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.content.ContentAttachmentData
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent 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, data class EnsureNativeWidgetAllowed(val widget: Widget,
val userJustAccepted: Boolean, val userJustAccepted: Boolean,
val grantedEvents: RoomDetailViewEvents) : RoomDetailAction() val grantedEvents: RoomDetailViewEvents) : RoomDetailAction()
data class UpdateJoinJitsiCallStatus(val conferenceEvent: ConferenceEvent) : RoomDetailAction() data class UpdateJoinJitsiCallStatus(val conferenceEvent: ConferenceEvent) : RoomDetailAction()
data class OpenOrCreateDm(val userId: String) : RoomDetailAction() data class OpenOrCreateDm(val userId: String) : RoomDetailAction()
@ -112,7 +112,4 @@ sealed class RoomDetailAction : VectorViewModelAction {
// Poll // Poll
data class EndPoll(val eventId: String) : RoomDetailAction() 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.PillImageSpan
import im.vector.app.features.html.PillsPostProcessor import im.vector.app.features.html.PillsPostProcessor
import im.vector.app.features.invite.VectorInviteView 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.LocationSharingMode
import im.vector.app.features.location.toLocationData
import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.media.ImageContentRenderer
import im.vector.app.features.media.VideoContentRenderer import im.vector.app.features.media.VideoContentRenderer
import im.vector.app.features.notifications.NotificationDrawerManager import im.vector.app.features.notifications.NotificationDrawerManager
@ -481,7 +481,6 @@ class RoomDetailFragment @Inject constructor(
RoomDetailViewEvents.StopChatEffects -> handleStopChatEffects() RoomDetailViewEvents.StopChatEffects -> handleStopChatEffects()
is RoomDetailViewEvents.DisplayAndAcceptCall -> acceptIncomingCall(it) is RoomDetailViewEvents.DisplayAndAcceptCall -> acceptIncomingCall(it)
RoomDetailViewEvents.RoomReplacementStarted -> handleRoomReplacement() RoomDetailViewEvents.RoomReplacementStarted -> handleRoomReplacement()
is RoomDetailViewEvents.ShowLocation -> handleShowLocationPreview(it)
}.exhaustive }.exhaustive
} }
@ -613,14 +612,14 @@ class RoomDetailFragment @Inject constructor(
} }
} }
private fun handleShowLocationPreview(viewEvent: RoomDetailViewEvents.ShowLocation) { private fun handleShowLocationPreview(locationContent: MessageLocationContent, senderId: String) {
navigator navigator
.openLocationSharing( .openLocationSharing(
context = requireContext(), context = requireContext(),
roomId = roomDetailArgs.roomId, roomId = roomDetailArgs.roomId,
mode = LocationSharingMode.PREVIEW, mode = LocationSharingMode.PREVIEW,
initialLocationData = viewEvent.locationData, initialLocationData = locationContent.toLocationData(),
locationOwnerId = viewEvent.userId locationOwnerId = senderId
) )
} }
@ -1828,6 +1827,12 @@ class RoomDetailFragment @Inject constructor(
is EncryptedEventContent -> { is EncryptedEventContent -> {
roomDetailViewModel.handle(RoomDetailAction.TapOnFailedToDecrypt(informationData.eventId)) 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) { when (action.messageContent) {
is MessageTextContent -> shareText(requireContext(), action.messageContent.body) is MessageTextContent -> shareText(requireContext(), action.messageContent.body)
is MessageLocationContent -> { is MessageLocationContent -> {
LocationData.create(action.messageContent.getUri())?.let { action.messageContent.toLocationData()?.let {
openLocation(requireActivity(), it.latitude, it.longitude) openLocation(requireActivity(), it.latitude, it.longitude)
} }
} }

View File

@ -20,7 +20,6 @@ import android.net.Uri
import android.view.View import android.view.View
import im.vector.app.core.platform.VectorViewEvents import im.vector.app.core.platform.VectorViewEvents
import im.vector.app.features.call.webrtc.WebRtcCall 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.session.widgets.model.Widget
import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.MatrixItem
import org.matrix.android.sdk.internal.crypto.model.event.WithHeldCode 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() data class StartChatEffect(val type: ChatEffect) : RoomDetailViewEvents()
object StopChatEffects : RoomDetailViewEvents() object StopChatEffects : RoomDetailViewEvents()
object RoomReplacementStarted : 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.factory.TimelineFactory
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever 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.home.room.typing.TypingHelper
import im.vector.app.features.location.LocationData
import im.vector.app.features.powerlevel.PowerLevelsFlowFactory import im.vector.app.features.powerlevel.PowerLevelsFlowFactory
import im.vector.app.features.session.coroutineScope import im.vector.app.features.session.coroutineScope
import im.vector.app.features.settings.VectorDataStore import im.vector.app.features.settings.VectorDataStore
@ -385,14 +384,9 @@ class RoomDetailViewModel @AssistedInject constructor(
_viewEvents.post(RoomDetailViewEvents.OpenRoom(action.replacementRoomId, closeCurrentRoom = true)) _viewEvents.post(RoomDetailViewEvents.OpenRoom(action.replacementRoomId, closeCurrentRoom = true))
} }
is RoomDetailAction.EndPoll -> handleEndPoll(action.eventId) is RoomDetailAction.EndPoll -> handleEndPoll(action.eventId)
is RoomDetailAction.ShowLocation -> handleShowLocation(action.locationData, action.userId)
}.exhaustive }.exhaustive
} }
private fun handleShowLocation(locationData: LocationData, userId: String) {
_viewEvents.post(RoomDetailViewEvents.ShowLocation(locationData, userId))
}
private fun handleJitsiCallJoinStatus(action: RoomDetailAction.UpdateJoinJitsiCallStatus) = withState { state -> private fun handleJitsiCallJoinStatus(action: RoomDetailAction.UpdateJoinJitsiCallStatus) = withState { state ->
if (state.jitsiState.confId == null) { if (state.jitsiState.confId == null) {
// If jitsi widget is removed while on the call // 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.createLinkMovementMethod
import im.vector.app.features.home.room.detail.timeline.tools.linkify import im.vector.app.features.home.room.detail.timeline.tools.linkify
import im.vector.app.features.html.SpanUtils 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.app.features.media.ImageContentRenderer
import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.orFalse
@ -62,6 +64,7 @@ class MessageActionsEpoxyController @Inject constructor(
private val spanUtils: SpanUtils, private val spanUtils: SpanUtils,
private val eventDetailsFormatter: EventDetailsFormatter, private val eventDetailsFormatter: EventDetailsFormatter,
private val dateFormatter: VectorDateFormatter, private val dateFormatter: VectorDateFormatter,
private val urlMapProvider: UrlMapProvider,
private val locationPinProvider: LocationPinProvider private val locationPinProvider: LocationPinProvider
) : TypedEpoxyController<MessageActionState>() { ) : TypedEpoxyController<MessageActionState>() {
@ -74,9 +77,11 @@ class MessageActionsEpoxyController @Inject constructor(
val formattedDate = dateFormatter.format(date, DateFormatKind.MESSAGE_DETAIL) val formattedDate = dateFormatter.format(date, DateFormatKind.MESSAGE_DETAIL)
val body = state.messageBody.linkify(host.listener) val body = state.messageBody.linkify(host.listener)
val bindingOptions = spanUtils.getBindingOptions(body) val bindingOptions = spanUtils.getBindingOptions(body)
val locationData = state.timelineEvent()?.root?.getClearContent()?.toModel<MessageLocationContent>(catchError = true)?.let { val locationUrl = state.timelineEvent()?.root?.getClearContent()
LocationData.create(it.getUri()) ?.toModel<MessageLocationContent>(catchError = true)
} ?.toLocationData()
?.let { urlMapProvider.buildStaticMapUrl(it, INITIAL_MAP_ZOOM_IN_TIMELINE, 1200, 800) }
bottomSheetMessagePreviewItem { bottomSheetMessagePreviewItem {
id("preview") id("preview")
avatarRenderer(host.avatarRenderer) avatarRenderer(host.avatarRenderer)
@ -89,7 +94,7 @@ class MessageActionsEpoxyController @Inject constructor(
body(body.toEpoxyCharSequence()) body(body.toEpoxyCharSequence())
bodyDetails(host.eventDetailsFormatter.format(state.timelineEvent()?.root)?.toEpoxyCharSequence()) bodyDetails(host.eventDetailsFormatter.format(state.timelineEvent()?.root)?.toEpoxyCharSequence())
time(formattedDate) time(formattedDate)
locationData(locationData) locationUrl(locationUrl)
locationPinProvider(host.locationPinProvider) locationPinProvider(host.locationPinProvider)
} }

View File

@ -16,6 +16,7 @@
package im.vector.app.features.home.room.detail.timeline.factory package im.vector.app.features.home.room.detail.timeline.factory
import android.content.res.Resources
import android.text.Spannable import android.text.Spannable
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.Spanned 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.resources.StringProvider
import im.vector.app.core.utils.DimensionConverter import im.vector.app.core.utils.DimensionConverter
import im.vector.app.core.utils.containsOnlyEmojis 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.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder 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.PillsPostProcessor
import im.vector.app.features.html.SpanUtils import im.vector.app.features.html.SpanUtils
import im.vector.app.features.html.VectorHtmlCompressor 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.ImageContentRenderer
import im.vector.app.features.media.VideoContentRenderer import im.vector.app.features.media.VideoContentRenderer
import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorPreferences
@ -127,7 +129,10 @@ class MessageItemFactory @Inject constructor(
private val session: Session, private val session: Session,
private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker, private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker,
private val locationPinProvider: LocationPinProvider, private val locationPinProvider: LocationPinProvider,
private val vectorPreferences: VectorPreferences) { private val vectorPreferences: VectorPreferences,
private val urlMapProvider: UrlMapProvider,
private val resources: Resources
) {
// TODO inject this properly? // TODO inject this properly?
private var roomId: String = "" private var roomId: String = ""
@ -182,7 +187,7 @@ class MessageItemFactory @Inject constructor(
is MessagePollContent -> buildPollItem(messageContent, informationData, highlight, callback, attributes) is MessagePollContent -> buildPollItem(messageContent, informationData, highlight, callback, attributes)
is MessageLocationContent -> { is MessageLocationContent -> {
if (vectorPreferences.labsRenderLocationsInTimeline()) { if (vectorPreferences.labsRenderLocationsInTimeline()) {
buildLocationItem(messageContent, informationData, highlight, callback, attributes) buildLocationItem(messageContent, informationData, highlight, attributes)
} else { } else {
buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes) buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes)
} }
@ -194,27 +199,21 @@ class MessageItemFactory @Inject constructor(
private fun buildLocationItem(locationContent: MessageLocationContent, private fun buildLocationItem(locationContent: MessageLocationContent,
informationData: MessageInformationData, informationData: MessageInformationData,
highlight: Boolean, highlight: Boolean,
callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes): MessageLocationItem? { attributes: AbsMessageItem.Attributes): MessageLocationItem? {
val geoUri = locationContent.getUri() val width = resources.displayMetrics.widthPixels - dimensionConverter.dpToPx(60)
val locationData = LocationData.create(geoUri) val height = dimensionConverter.dpToPx(200)
val mapCallback: MessageLocationItem.Callback = object : MessageLocationItem.Callback { val locationUrl = locationContent.toLocationData()?.let {
override fun onMapClicked() { urlMapProvider.buildStaticMapUrl(it, INITIAL_MAP_ZOOM_IN_TIMELINE, width, height)
locationData?.let {
callback?.onTimelineItemAction(RoomDetailAction.ShowLocation(it, informationData.senderId))
}
}
} }
return MessageLocationItem_() return MessageLocationItem_()
.attributes(attributes) .attributes(attributes)
.locationData(locationData) .locationUrl(locationUrl)
.userId(informationData.senderId) .userId(informationData.senderId)
.locationPinProvider(locationPinProvider) .locationPinProvider(locationPinProvider)
.highlighted(highlight) .highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline) .leftGuideline(avatarSizeProvider.leftGuideline)
.callback(mapCallback)
} }
private fun buildPollItem(pollContent: MessagePollContent, 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.core.utils.DimensionConverter
import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.AvatarRenderer
import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -54,22 +55,36 @@ class LocationPinProvider @Inject constructor(
val size = dimensionConverter.dpToPx(44) val size = dimensionConverter.dpToPx(44)
avatarRenderer.render(glideRequests, it, object : CustomTarget<Drawable>(size, size) { avatarRenderer.render(glideRequests, it, object : CustomTarget<Drawable>(size, size) {
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) { override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
val bgUserPin = ContextCompat.getDrawable(context, R.drawable.bg_map_user_pin)!! Timber.d("## Location: onResourceReady")
val layerDrawable = LayerDrawable(arrayOf(bgUserPin, resource)) val pinDrawable = createPinDrawable(resource)
val horizontalInset = dimensionConverter.dpToPx(4) cache[userId] = pinDrawable
val topInset = dimensionConverter.dpToPx(4) callback(pinDrawable)
val bottomInset = dimensionConverter.dpToPx(8)
layerDrawable.setLayerInset(1, horizontalInset, topInset, horizontalInset, bottomInset)
cache[userId] = layerDrawable
callback(layerDrawable)
} }
override fun onLoadCleared(placeholder: Drawable?) { override fun onLoadCleared(placeholder: Drawable?) {
// Is it possible? Put placeholder instead? // 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 package im.vector.app.features.home.room.detail.timeline.item
import android.widget.FrameLayout import android.widget.ImageView
import androidx.constraintlayout.widget.ConstraintLayout
import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
import com.bumptech.glide.request.RequestOptions
import im.vector.app.R 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.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) @EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class MessageLocationItem : AbsMessageItem<MessageLocationItem.Holder>() { abstract class MessageLocationItem : AbsMessageItem<MessageLocationItem.Holder>() {
interface Callback {
fun onMapClicked()
}
@EpoxyAttribute @EpoxyAttribute
var callback: Callback? = null var locationUrl: String? = null
@EpoxyAttribute
var locationData: LocationData? = null
@EpoxyAttribute @EpoxyAttribute
var userId: String? = null var userId: String? = null
@ -47,37 +38,31 @@ abstract class MessageLocationItem : AbsMessageItem<MessageLocationItem.Holder>(
override fun bind(holder: Holder) { override fun bind(holder: Holder) {
super.bind(holder) super.bind(holder)
renderSendState(holder.mapViewContainer, null) renderSendState(holder.view, null)
val location = locationData ?: return val location = locationUrl ?: return
val locationOwnerId = userId ?: return val locationOwnerId = userId ?: return
holder.clickableMapArea.onClick { GlideApp.with(holder.staticMapImageView)
callback?.onMapClicked() .load(location)
} .apply(RequestOptions.centerCropTransform())
.into(holder.staticMapImageView)
holder.mapView.apply {
initialize {
zoomToLocation(location.latitude, location.longitude, INITIAL_ZOOM)
locationPinProvider?.create(locationOwnerId) { pinDrawable -> locationPinProvider?.create(locationOwnerId) { pinDrawable ->
addPinToMap(locationOwnerId, pinDrawable) GlideApp.with(holder.staticMapPinImageView)
updatePinLocation(locationOwnerId, location.latitude, location.longitude) .load(pinDrawable)
} .into(holder.staticMapPinImageView)
}
} }
} }
override fun getViewType() = STUB_ID override fun getViewType() = STUB_ID
class Holder : AbsMessageItem.Holder(STUB_ID) { class Holder : AbsMessageItem.Holder(STUB_ID) {
val mapViewContainer by bind<ConstraintLayout>(R.id.mapViewContainer) val staticMapImageView by bind<ImageView>(R.id.staticMapImageView)
val mapView by bind<MapTilerMapView>(R.id.mapView) val staticMapPinImageView by bind<ImageView>(R.id.staticMapPinImageView)
val clickableMapArea by bind<FrameLayout>(R.id.clickableMapArea)
} }
companion object { companion object {
private const val STUB_ID = R.id.messageContentLocationStub 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 package im.vector.app.features.location
const val INITIAL_MAP_ZOOM = 15.0 const val MAP_BASE_URL = "https://api.maptiler.com/maps/streets/style.json"
const val MIN_TIME_MILLIS_TO_UPDATE_LOCATION = 1 * 60 * 1000L // every 1 minute const val STATIC_MAP_BASE_URL = "https://api.maptiler.com/maps/basic/static/"
const val MIN_DISTANCE_METERS_TO_UPDATE_LOCATION = 10f
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 package im.vector.app.features.location
import android.os.Parcelable import android.os.Parcelable
import androidx.annotation.VisibleForTesting
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.matrix.android.sdk.api.session.room.model.message.MessageLocationContent
@Parcelize @Parcelize
data class LocationData( data class LocationData(
val latitude: Double, val latitude: Double,
val longitude: Double, val longitude: Double,
val uncertainty: Double? val uncertainty: Double?
) : Parcelable { ) : Parcelable
companion object { /**
* Creates location data from a LocationContent
/** * "geo:40.05,29.24;30" -> LocationData(40.05, 29.24, 30)
* Creates location data from geo uri
* @param geoUri geo:latitude,longitude;uncertainty
* @return location data or null if geo uri is not valid * @return location data or null if geo uri is not valid
*/ */
fun create(geoUri: String): LocationData? { fun MessageLocationContent.toLocationData(): LocationData? {
val geoParts = geoUri return parseGeo(getBestGeoUri())
}
@VisibleForTesting
fun parseGeo(geo: String): LocationData? {
val geoParts = geo
.split(":") .split(":")
.takeIf { it.firstOrNull() == "geo" } .takeIf { it.firstOrNull() == "geo" }
?.getOrNull(1) ?.getOrNull(1)
?.split(",") ?.split(";") ?: return null
val latitude = geoParts?.firstOrNull() val gpsParts = geoParts.getOrNull(0)?.split(",") ?: return null
val geoTailParts = geoParts?.getOrNull(1)?.split(";") val lat = gpsParts.getOrNull(0)?.toDoubleOrNull() ?: return null
val longitude = geoTailParts?.firstOrNull() val lng = gpsParts.getOrNull(1)?.toDoubleOrNull() ?: return null
val uncertainty = geoTailParts?.getOrNull(1)?.replace("u=", "")
return if (latitude != null && longitude != null) { val uncertainty = geoParts.getOrNull(1)?.replace("u=", "")?.toDoubleOrNull()
LocationData(
latitude = latitude.toDouble(), return LocationData(
longitude = longitude.toDouble(), latitude = lat,
uncertainty = uncertainty?.toDouble() longitude = lng,
uncertainty = uncertainty
) )
} else null
}
}
} }

View File

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

View File

@ -20,29 +20,29 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isGone
import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.mapbox.mapboxsdk.maps.MapView
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.exhaustive import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentLocationSharingBinding import im.vector.app.databinding.FragmentLocationSharingBinding
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider import java.lang.ref.WeakReference
import org.matrix.android.sdk.api.session.Session
import javax.inject.Inject import javax.inject.Inject
/**
* We should consider using SupportMapFragment for a out of the box lifecycle handling
*/
class LocationSharingFragment @Inject constructor( class LocationSharingFragment @Inject constructor(
private val locationTracker: LocationTracker, private val urlMapProvider: UrlMapProvider
private val session: Session, ) : VectorBaseFragment<FragmentLocationSharingBinding>() {
private val locationPinProvider: LocationPinProvider
) : VectorBaseFragment<FragmentLocationSharingBinding>(), LocationTracker.Callback {
init {
locationTracker.callback = this
}
private val viewModel: LocationSharingViewModel by fragmentViewModel() 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 { override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLocationSharingBinding {
return FragmentLocationSharingBinding.inflate(inflater, container, false) return FragmentLocationSharingBinding.inflate(inflater, container, false)
@ -51,11 +51,9 @@ class LocationSharingFragment @Inject constructor(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
views.mapView.initialize { mapView = WeakReference(views.mapView)
if (isAdded) { views.mapView.onCreate(savedInstanceState)
onMapReady() views.mapView.initialize(urlMapProvider.mapUrl)
}
}
views.shareLocationContainer.debouncedClicks { views.shareLocationContainer.debouncedClicks {
viewModel.handle(LocationSharingAction.OnShareLocation) viewModel.handle(LocationSharingAction.OnShareLocation)
@ -69,48 +67,42 @@ class LocationSharingFragment @Inject constructor(
} }
} }
override fun onResume() {
super.onResume()
views.mapView.onResume()
}
override fun onPause() { override fun onPause() {
views.mapView.onPause() views.mapView.onPause()
super.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() { override fun onStop() {
views.mapView.onStop() views.mapView.onStop()
super.onStop() super.onStop()
} }
override fun onLowMemory() {
super.onLowMemory()
views.mapView.onLowMemory()
}
override fun onDestroy() { override fun onDestroy() {
locationTracker.stop() mapView?.get()?.onDestroy()
mapView?.clear()
super.onDestroy() 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() { private fun handleLocationNotAvailableError() {
MaterialAlertDialogBuilder(requireActivity()) MaterialAlertDialogBuilder(requireActivity())
.setTitle(R.string.location_not_available_dialog_title) .setTitle(R.string.location_not_available_dialog_title)
@ -118,9 +110,15 @@ class LocationSharingFragment @Inject constructor(
.setPositiveButton(R.string.ok) { _, _ -> .setPositiveButton(R.string.ok) { _, _ ->
activity?.finish() activity?.finish()
} }
.setCancelable(false)
.show() .show()
} }
override fun invalidate() = withState(viewModel) { state ->
views.mapView.render(state.toMapState())
views.shareLocationGpsLoading.isGone = state.lastKnownLocation != null
}
companion object { companion object {
const val USER_PIN_NAME = "USER_PIN_NAME" 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.di.hiltMavericksViewModelFactory
import im.vector.app.core.extensions.exhaustive import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel 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 import org.matrix.android.sdk.api.session.Session
class LocationSharingViewModel @AssistedInject constructor( class LocationSharingViewModel @AssistedInject constructor(
@Assisted private val initialState: LocationSharingViewState, @Assisted private val initialState: LocationSharingViewState,
session: Session private val locationTracker: LocationTracker,
) : VectorViewModel<LocationSharingViewState, LocationSharingAction, LocationSharingViewEvents>(initialState) { private val locationPinProvider: LocationPinProvider,
private val session: Session
) : VectorViewModel<LocationSharingViewState, LocationSharingAction, LocationSharingViewEvents>(initialState), LocationTracker.Callback {
private val room = session.getRoom(initialState.roomId)!! private val room = session.getRoom(initialState.roomId)!!
@ -38,14 +41,31 @@ class LocationSharingViewModel @AssistedInject constructor(
override fun create(initialState: LocationSharingViewState): LocationSharingViewModel 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) { override fun handle(action: LocationSharingAction) {
when (action) { when (action) {
is LocationSharingAction.OnLocationUpdate -> handleLocationUpdate(action.locationData)
LocationSharingAction.OnShareLocation -> handleShareLocation() LocationSharingAction.OnShareLocation -> handleShareLocation()
LocationSharingAction.OnLocationProviderIsNotAvailable -> handleLocationProviderIsNotAvailable()
}.exhaustive }.exhaustive
} }
@ -62,13 +82,13 @@ class LocationSharingViewModel @AssistedInject constructor(
} }
} }
private fun handleLocationUpdate(locationData: LocationData) { override fun onLocationUpdate(locationData: LocationData) {
setState { setState {
copy(lastKnownLocation = locationData) copy(lastKnownLocation = locationData)
} }
} }
private fun handleLocationProviderIsNotAvailable() { override fun onLocationProviderIsNotAvailable() {
_viewEvents.post(LocationSharingViewEvents.LocationNotAvailableError) _viewEvents.post(LocationSharingViewEvents.LocationNotAvailableError)
} }
} }

View File

@ -16,6 +16,7 @@
package im.vector.app.features.location package im.vector.app.features.location
import android.graphics.drawable.Drawable
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksState
import im.vector.app.R import im.vector.app.R
@ -28,7 +29,8 @@ enum class LocationSharingMode(@StringRes val titleRes: Int) {
data class LocationSharingViewState( data class LocationSharingViewState(
val roomId: String, val roomId: String,
val mode: LocationSharingMode, val mode: LocationSharingMode,
val lastKnownLocation: LocationData? = null val lastKnownLocation: LocationData? = null,
val pinDrawable: Drawable? = null
) : MavericksState { ) : MavericksState {
constructor(locationSharingArgs: LocationSharingArgs) : this( constructor(locationSharingArgs: LocationSharingArgs) : this(
@ -36,3 +38,10 @@ data class LocationSharingViewState(
mode = locationSharingArgs.mode 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.Manifest
import android.content.Context import android.content.Context
import android.location.Location import android.location.Location
import android.location.LocationListener
import android.location.LocationManager import android.location.LocationManager
import androidx.annotation.RequiresPermission import androidx.annotation.RequiresPermission
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.location.LocationListenerCompat
import im.vector.app.BuildConfig
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
class LocationTracker @Inject constructor( class LocationTracker @Inject constructor(
private val context: Context context: Context
) : LocationListener { ) : LocationListenerCompat {
private val locationManager = context.getSystemService<LocationManager>()
interface Callback { interface Callback {
fun onLocationUpdate(locationData: LocationData) fun onLocationUpdate(locationData: LocationData)
fun onLocationProviderIsNotAvailable() fun onLocationProviderIsNotAvailable()
} }
private var locationManager: LocationManager? = null private var callback: Callback? = null
var callback: Callback? = null
private var hasGpsProviderLiveLocation = false
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) @RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
fun start() { fun start(callback: Callback?) {
val locationManager = context.getSystemService<LocationManager>() Timber.d("## LocationTracker. start()")
hasGpsProviderLiveLocation = false
this.callback = callback
locationManager?.let { if (locationManager == null) {
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 {
callback?.onLocationProviderIsNotAvailable() callback?.onLocationProviderIsNotAvailable()
Timber.v("## LocationTracker. LocationManager is not available") 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]) @RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
fun stop() { fun stop() {
Timber.d("## LocationTracker. stop()")
locationManager?.removeUpdates(this) locationManager?.removeUpdates(this)
callback = null callback = null
} }
override fun onLocationChanged(location: Location) { 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()) callback?.onLocationUpdate(location.toLocationData())
} }
override fun onProviderDisabled(provider: String) {
Timber.d("## LocationTracker. onProviderDisabled: $provider")
callback?.onLocationProviderIsNotAvailable()
}
private fun Location.toLocationData(): LocationData { private fun Location.toLocationData(): LocationData {
return LocationData(latitude, longitude, accuracy.toDouble()) 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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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 import android.graphics.drawable.Drawable
interface VectorMapView { data class MapState(
fun initialize(onMapReady: () -> Unit) val zoomOnlyOnce: Boolean,
val pinLocationData: LocationData? = null,
fun addPinToMap(pinId: String, image: Drawable) val pinId: String,
fun updatePinLocation(pinId: String, latitude: Double, longitude: Double) val pinDrawable: Drawable? = null
fun deleteAllPins() )
fun zoomToLocation(latitude: Double, longitude: Double, zoom: Double)
fun getCurrentZoom(): Double?
fun onClick(callback: () -> Unit)
}

View File

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

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:textColor="?colorPrimary"
android:textStyle="bold" /> 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> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -103,18 +103,34 @@
tools:text="1080 x 1024 - 43s - 12kB" tools:text="1080 x 1024 - 43s - 12kB"
tools:visibility="visible" /> 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_width="0dp"
android:layout_height="200dp" android:layout_height="wrap_content"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:contentDescription="@string/attachment_type_location"
android:scaleType="centerCrop"
android:visibility="gone" android:visibility="gone"
app:cardCornerRadius="8dp"
app:layout_constraintEnd_toEndOf="@id/bottom_sheet_message_preview_timestamp" app:layout_constraintEnd_toEndOf="@id/bottom_sheet_message_preview_timestamp"
app:layout_constraintStart_toStartOf="@id/bottom_sheet_message_preview_sender" app:layout_constraintStart_toStartOf="@id/bottom_sheet_message_preview_sender"
app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message_preview_sender" app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message_preview_sender"
app:mapbox_renderTextureMode="true" tools:alpha="0.3"
tools:visibility="visible" /> 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> </androidx.constraintlayout.widget.ConstraintLayout>

View File

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