diff --git a/changelog.d/5084.bugfix b/changelog.d/5084.bugfix new file mode 100644 index 0000000000..95a3a20cd1 --- /dev/null +++ b/changelog.d/5084.bugfix @@ -0,0 +1 @@ +Display static map images in the timeline and improve Location sharing feature \ No newline at end of file diff --git a/library/ui-styles/src/main/res/values-ldrtl/bools.xml b/library/ui-styles/src/main/res/values-ldrtl/bools.xml new file mode 100644 index 0000000000..27b280985f --- /dev/null +++ b/library/ui-styles/src/main/res/values-ldrtl/bools.xml @@ -0,0 +1,6 @@ + + + + true + + \ No newline at end of file diff --git a/library/ui-styles/src/main/res/values/bools.xml b/library/ui-styles/src/main/res/values/bools.xml index 93d5f925af..9966999f28 100644 --- a/library/ui-styles/src/main/res/values/bools.xml +++ b/library/ui-styles/src/main/res/values/bools.xml @@ -4,4 +4,6 @@ false + false + \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt index bf51e7177b..c090487c58 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt @@ -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 } diff --git a/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt b/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt index cdecd2d6c6..5295cbaec3 100644 --- a/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt +++ b/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt @@ -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 - 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(R.id.bottom_sheet_message_preview_body_details) val timestamp by bind(R.id.bottom_sheet_message_preview_timestamp) val imagePreview by bind(R.id.bottom_sheet_message_preview_image) - val mapView by bind(R.id.bottom_sheet_message_preview_location) + val mapViewContainer by bind(R.id.mapViewContainer) + val staticMapImageView by bind(R.id.staticMapImageView) + val staticMapPinImageView by bind(R.id.staticMapPinImageView) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt index 58e36d2303..14c8e598f8 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt @@ -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() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt index 9926ecad24..b58a1d627e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt @@ -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) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt index b0921e01f9..86240a5ffe 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt @@ -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() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt index 6e14b0fc76..9149ae1dca 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt index 1ff9679479..086a093068 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt @@ -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() { @@ -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(catchError = true)?.let { - LocationData.create(it.getUri()) - } + val locationUrl = state.timelineEvent()?.root?.getClearContent() + ?.toModel(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) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index eab7621d14..352b87a4d8 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -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, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/LocationPinProvider.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/LocationPinProvider.kt index fe3a7d9007..e92376c44d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/LocationPinProvider.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/LocationPinProvider.kt @@ -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(size, size) { override fun onResourceReady(resource: Drawable, transition: Transition?) { - 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 + } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLocationItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLocationItem.kt index 3f030866a5..6f0b6abb72 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLocationItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLocationItem.kt @@ -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() { - 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( 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(R.id.mapViewContainer) - val mapView by bind(R.id.mapView) - val clickableMapArea by bind(R.id.clickableMapArea) + val staticMapImageView by bind(R.id.staticMapImageView) + val staticMapPinImageView by bind(R.id.staticMapPinImageView) } companion object { private const val STUB_ID = R.id.messageContentLocationStub - private const val INITIAL_ZOOM = 15.0 } } diff --git a/vector/src/main/java/im/vector/app/features/location/Config.kt b/vector/src/main/java/im/vector/app/features/location/Config.kt index 630df16a37..29ca6b81a9 100644 --- a/vector/src/main/java/im/vector/app/features/location/Config.kt +++ b/vector/src/main/java/im/vector/app/features/location/Config.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/location/LocationData.kt b/vector/src/main/java/im/vector/app/features/location/LocationData.kt index c3ff09ebcd..a69d8d20e3 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationData.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationData.kt @@ -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 + ) } diff --git a/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt b/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt index 6209bf5a4f..c4f2f148bf 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt @@ -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() { private val args: LocationSharingArgs by args() + // Keep a ref to handle properly the onDestroy callback + private var mapView: WeakReference? = 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 + ) + ) } } } diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt index 71101d0612..01319ef6c7 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt @@ -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() } diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt index 900f465f04..f6bad2826b 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt @@ -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(), LocationTracker.Callback { - - init { - locationTracker.callback = this - } + private val urlMapProvider: UrlMapProvider +) : VectorBaseFragment() { 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? = 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" } diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt index b3c97310e1..f4e1fd0281 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt @@ -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(initialState) { + private val locationTracker: LocationTracker, + private val locationPinProvider: LocationPinProvider, + private val session: Session +) : VectorViewModel(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 by hiltMavericksViewModelFactory() { + companion object : MavericksViewModelFactory 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) } } diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt index 2869929b12..f3b937855a 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt @@ -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 +) diff --git a/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt b/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt index 0c0315cf34..162fbc5959 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt @@ -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() 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() + 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()) } diff --git a/vector/src/main/java/im/vector/app/features/location/VectorMapView.kt b/vector/src/main/java/im/vector/app/features/location/MapState.kt similarity index 61% rename from vector/src/main/java/im/vector/app/features/location/VectorMapView.kt rename to vector/src/main/java/im/vector/app/features/location/MapState.kt index 23b59bf99a..d001457e4f 100644 --- a/vector/src/main/java/im/vector/app/features/location/VectorMapView.kt +++ b/vector/src/main/java/im/vector/app/features/location/MapState.kt @@ -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 +) diff --git a/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt b/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt index c64af1ebaa..dd80f701f6 100644 --- a/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt +++ b/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt @@ -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() } } diff --git a/vector/src/main/java/im/vector/app/features/location/UrlMapProvider.kt b/vector/src/main/java/im/vector/app/features/location/UrlMapProvider.kt new file mode 100644 index 0000000000..76d44f5ece --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/UrlMapProvider.kt @@ -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") + } + } + } +} diff --git a/vector/src/main/res/layout/fragment_location_sharing.xml b/vector/src/main/res/layout/fragment_location_sharing.xml index f9a37a6241..ad418f3e1c 100644 --- a/vector/src/main/res/layout/fragment_location_sharing.xml +++ b/vector/src/main/res/layout/fragment_location_sharing.xml @@ -48,4 +48,13 @@ android:textColor="?colorPrimary" android:textStyle="bold" /> + + \ No newline at end of file diff --git a/vector/src/main/res/layout/item_bottom_sheet_message_preview.xml b/vector/src/main/res/layout/item_bottom_sheet_message_preview.xml index 95e6975803..e40760e046 100644 --- a/vector/src/main/res/layout/item_bottom_sheet_message_preview.xml +++ b/vector/src/main/res/layout/item_bottom_sheet_message_preview.xml @@ -103,18 +103,34 @@ tools:text="1080 x 1024 - 43s - 12kB" tools:visibility="visible" /> - + tools:alpha="0.3" + tools:visibility="visible"> + + + + + diff --git a/vector/src/main/res/layout/item_timeline_event_location_stub.xml b/vector/src/main/res/layout/item_timeline_event_location_stub.xml index b2f68b2fc3..316470b5f1 100644 --- a/vector/src/main/res/layout/item_timeline_event_location_stub.xml +++ b/vector/src/main/res/layout/item_timeline_event_location_stub.xml @@ -6,30 +6,19 @@ android:layout_height="wrap_content" app:cardCornerRadius="8dp"> - + android:layout_height="200dp" + android:contentDescription="@string/a11y_static_map_image" /> - - - - - + diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 22c890eb01..378b8d7cbf 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -3711,6 +3711,7 @@ Share location Location Share location + Map Share location ${app_name} could not access your location ${app_name} could not access your location. Please try again later. diff --git a/vector/src/test/java/im/vector/app/features/location/LocationDataTest.kt b/vector/src/test/java/im/vector/app/features/location/LocationDataTest.kt new file mode 100644 index 0000000000..fcfff0096f --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/location/LocationDataTest.kt @@ -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() + } +}